diff --git a/.eslintignore b/.eslintignore index 63cd01d6e90db..f757ed9a1bf98 100644 --- a/.eslintignore +++ b/.eslintignore @@ -30,8 +30,6 @@ snapshots.js # package overrides /packages/elastic-eslint-config-kibana -/packages/kbn-interpreter/src/common/lib/grammar.js -/packages/kbn-tinymath/src/grammar.js /packages/kbn-plugin-generator/template /packages/kbn-pm/dist /packages/kbn-test/src/functional_test_runner/__tests__/fixtures/ diff --git a/.eslintrc.js b/.eslintrc.js index 40dd6a55a2a3f..2eea41984b30e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -857,6 +857,18 @@ module.exports = { 'react-hooks/exhaustive-deps': ['error', { additionalHooks: '^useFetcher$' }], }, }, + { + files: ['x-pack/plugins/apm/**/*.stories.*', 'x-pack/plugins/observability/**/*.stories.*'], + rules: { + 'react/function-component-definition': [ + 'off', + { + namedComponents: 'function-declaration', + unnamedComponents: 'arrow-function', + }, + ], + }, + }, /** * Fleet overrides @@ -893,6 +905,8 @@ module.exports = { files: [ 'x-pack/plugins/security_solution/public/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/security_solution/common/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/timelines/public/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/timelines/common/**/*.{js,mjs,ts,tsx}', ], rules: { 'import/no-nodejs-modules': 'error', @@ -907,7 +921,10 @@ module.exports = { }, { // typescript only for front and back end - files: ['x-pack/plugins/security_solution/**/*.{ts,tsx}'], + files: [ + 'x-pack/plugins/security_solution/**/*.{ts,tsx}', + 'x-pack/plugins/timelines/**/*.{ts,tsx}', + ], rules: { '@typescript-eslint/no-this-alias': 'error', '@typescript-eslint/no-explicit-any': 'error', @@ -917,7 +934,10 @@ module.exports = { }, { // typescript and javascript for front and back end - files: ['x-pack/plugins/security_solution/**/*.{js,mjs,ts,tsx}'], + files: [ + 'x-pack/plugins/security_solution/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/timelines/**/*.{js,mjs,ts,tsx}', + ], plugins: ['eslint-plugin-node', 'react'], env: { jest: true, diff --git a/NOTICE.txt b/NOTICE.txt index 4eec329b7a603..4ede43610ca7b 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -149,17 +149,17 @@ SOFTWARE. --- Detection Rules -Copyright 2020 Elasticsearch B.V. +Copyright 2021 Elasticsearch B.V. --- This product bundles rules based on https://github.com/BlueTeamLabs/sentinel-attack -which is available under a "MIT" license. The files based on this license are: +which is available under a "MIT" license. The rules based on this license are: -- defense_evasion_via_filter_manager -- discovery_process_discovery_via_tasklist_command -- persistence_priv_escalation_via_accessibility_features -- persistence_via_application_shimming -- defense_evasion_execution_via_trusted_developer_utilities +- "Potential Evasion via Filter Manager" (06dceabf-adca-48af-ac79-ffdf4c3b1e9a) +- "Process Discovery via Tasklist" (cc16f774-59f9-462d-8b98-d27ccd4519ec) +- "Potential Modification of Accessibility Binaries" (7405ddf1-6c8e-41ce-818f-48bea6bcaed8) +- "Potential Application Shimming via Sdbinst" (fd4a992d-6130-4802-9ff8-829b89ae801f) +- "Trusted Developer Application Usage" (9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae1) MIT License @@ -185,9 +185,9 @@ SOFTWARE. --- This product bundles rules based on https://github.com/FSecureLABS/leonidas -which is available under a "MIT" license. The files based on this license are: +which is available under a "MIT" license. The rules based on this license are: -- credential_access_secretsmanager_getsecretvalue.toml +- "AWS Access Secret in Secrets Manager" (a00681e3-9ed6-447c-ab2c-be648821c622) MIT License @@ -235,6 +235,10 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- +Portions of this code are licensed under the following license: +For license information please see https://edge.fullstory.com/s/fs.js.LICENSE.txt + --- This product bundles bootstrap@3.3.6 which is available under a "MIT" license. diff --git a/dev_docs/tutorials/setting_up_a_development_env.mdx b/dev_docs/tutorials/setting_up_a_development_env.mdx new file mode 100644 index 0000000000000..449e8b886a44d --- /dev/null +++ b/dev_docs/tutorials/setting_up_a_development_env.mdx @@ -0,0 +1,89 @@ +--- +id: kibDevTutorialSetupDevEnv +slug: /kibana-dev-docs/tutorial/setup-dev-env +title: Setting up a Development Environment +summary: Learn how to setup a development environemnt for contributing to the Kibana repository +date: 2021-04-26 +tags: ['kibana', 'onboarding', 'dev', 'architecture', 'setup'] +--- + +Setting up a development environment is pretty easy. + + + In order to support Windows development we currently require you to use one of the following: + + - [Git Bash](https://git-scm.com/download/win) + - [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) + + + Before running the steps below, please make sure you have installed [Visual C++ Redistributable for Visual Studio 2015](https://www.microsoft.com/en-us/download/details.aspx?id=48145) and that you are running all commands in either Git Bash or WSL. + + +## Get the code + +Start by forking [the Kibana repository](https://github.com/elastic/kibana) on Github so that you have a place to stage pull requests and create branches for development. + +Then clone the repository to your machine: + +```sh +git clone https://github.com/[YOUR_USERNAME]/kibana.git kibana +cd kibana +``` + +## Install dependencies + +Install the version of Node.js listed in the `.node-version` file. This can be automated with tools such as [nvm](https://github.com/creationix/nvm) or [nvm-windows](https://github.com/coreybutler/nvm-windows). As we also include a `.nvmrc` file you can switch to the correct version when using nvm by running: + +```sh +nvm use +``` + +Then, install the latest version of yarn using: + +```sh +npm install -g yarn +``` + +Finally, boostrap Kibana and install all of the remaining dependencies: + +```sh +yarn kbn bootstrap +``` + +Node.js native modules could be in use and node-gyp is the tool used to build them. There are tools you need to install per platform and python versions you need to be using. Please follow the [node-gyp installation steps](https://github.com/nodejs/node-gyp#installation) for your platform. + +## Run Elasticsearch + +In order to start Kibana you need to run a local version of Elasticsearch. You can startup and initialize the latest Elasticsearch snapshot of the correct version for Kibana by running the following in a new terminal tab/window: + +```sh +yarn es snapshot +``` + +You can pass `--license trial` to start Elasticsearch with a trial license, or use the Kibana UI to switch the local version to a trial version which includes all features. + +Read about more options for [Running Elasticsearch during development](https://www.elastic.co/guide/en/kibana/current/running-elasticsearch.html), like connecting to a remote host, running from source, preserving data inbetween runs, running remote cluster, etc. + +## Run Kibana + +In another terminal tab/window you can start Kibana. + +```sh +yarn start +``` + +If you include the `--run-examples` flag then all of the [developer examples](https://github.com/elastic/kibana/tree/{branch}/examples). Read more about the advanced options for [Running Kibana](https://www.elastic.co/guide/en/kibana/current/running-kibana-advanced.html). + +## Code away! + +You are now ready to start developing. Changes to the source files should be picked up automatically and either cause the server to restart, or be served to the browser on the next page refresh. + +## Install pre-commit hook (optional) + +In case you want to run a couple of checks like linting or check the file casing of the files to commit, we provide a way to install a pre-commit hook. To configure it you just need to run the following: + +```sh +node scripts/register_git_hook +``` + +After the script completes the pre-commit hook will be created within the file `.git/hooks/pre-commit`. If you choose to not install it, don’t worry, we still run a quick CI check to provide feedback earliest as we can about the same checks. diff --git a/docs/api/alerting/legacy/list.asciidoc b/docs/api/alerting/legacy/list.asciidoc index be37be36cd0e8..07307797c4223 100644 --- a/docs/api/alerting/legacy/list.asciidoc +++ b/docs/api/alerting/legacy/list.asciidoc @@ -80,6 +80,7 @@ The API returns the following: }, "producer":"stackAlerts", "minimumLicenseRequired":"basic", + "isExportable":true, "enabledInLicense":true, "authorizedConsumers":{ "alerts":{ @@ -113,6 +114,9 @@ Each alert type contains the following properties: | `minimumLicenseRequired` | The license required to use the alert type. +| `isExportable` +| Whether the rule type is exportable through the Saved Objects Management UI. + | `enabledInLicense` | Whether the alert type is enabled or disabled based on the license. diff --git a/docs/api/alerting/list_rule_types.asciidoc b/docs/api/alerting/list_rule_types.asciidoc index 31c8416e75059..21ace9f3105c0 100644 --- a/docs/api/alerting/list_rule_types.asciidoc +++ b/docs/api/alerting/list_rule_types.asciidoc @@ -82,6 +82,7 @@ The API returns the following: }, "producer":"stackAlerts", "minimum_license_required":"basic", + "is_exportable":true, "enabled_in_license":true, "authorized_consumers":{ "alerts":{ @@ -115,6 +116,9 @@ Each rule type contains the following properties: | `minimum_license_required` | The license required to use the rule type. +| `is_exportable` +| Whether the rule type is exportable through the Saved Objects Management UI. + | `enabled_in_license` | Whether the rule type is enabled or disabled based on the license. diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 267ab3891d700..5bd3a7587dde9 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -45,6 +45,11 @@ experimental[] Create multiple {kib} saved objects. (Optional, string array) Identifiers for the <> in which this object is created. If this is provided, the object is created only in the explicitly defined spaces. If this is not provided, the object is created in the current space (default behavior). +* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including +the "All spaces" identifier (`'*'`). +* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be +used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. +* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. `version`:: (Optional, number) Specifies the version. diff --git a/docs/api/saved-objects/create.asciidoc b/docs/api/saved-objects/create.asciidoc index d7a368034ef07..e7e25c7d3bba6 100644 --- a/docs/api/saved-objects/create.asciidoc +++ b/docs/api/saved-objects/create.asciidoc @@ -52,6 +52,11 @@ any data that you send to the API is properly formed. (Optional, string array) Identifiers for the <> in which this object is created. If this is provided, the object is created only in the explicitly defined spaces. If this is not provided, the object is created in the current space (default behavior). +* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including +the "All spaces" identifier (`'*'`). +* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be +used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. +* For global object types (registered with `namespaceType: 'agnostic'): this option cannot be used. [[saved-objects-api-create-request-codes]] ==== Response code diff --git a/docs/api/task-manager/health.asciidoc b/docs/api/task-manager/health.asciidoc index 22006725da00c..758e3fdab1ca4 100644 --- a/docs/api/task-manager/health.asciidoc +++ b/docs/api/task-manager/health.asciidoc @@ -1,24 +1,27 @@ [[task-manager-api-health]] -=== Get Task Manager health API +== Task Manager health API ++++ Get Task Manager health ++++ Retrieve the health status of the {kib} Task Manager. +[float] [[task-manager-api-health-request]] -==== Request +=== Request `GET :/api/task_manager/_health` +[float] [[task-manager-api-health-codes]] -==== Response code +=== Response code `200`:: Indicates a successful call. +[float] [[task-manager-api-health-example]] -==== Example +=== Example Retrieve the health status of the {kib} Task Manager: diff --git a/docs/developer/best-practices/navigation.asciidoc b/docs/developer/best-practices/navigation.asciidoc index d01f2c2aa0f95..32946a2f74bd9 100644 --- a/docs/developer/best-practices/navigation.asciidoc +++ b/docs/developer/best-practices/navigation.asciidoc @@ -47,24 +47,26 @@ console.log(discoverUrl); // http://localhost:5601/bpr/s/space/app/discover const discoverUrlWithSomeState = core.http.basePath.prepend(`/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:'2020-09-10T11:39:50.203Z',to:'2020-09-10T11:40:20.249Z'))&_a=(columns:!(_source),filters:!(),index:'90943e30-9a47-11e8-b64d-95841ca0b247',interval:auto,query:(language:kuery,query:''),sort:!())`); ---- -Instead, each app should expose {kib-repo}tree/{branch}/src/plugins/share/public/url_generators/README.md[a URL generator]. -Other apps should use those URL generators for creating URLs. +Instead, each app should expose {kib-repo}tree/{branch}/src/plugins/share/common/url_service/locators/README.md[a locator]. +Other apps should use those locators for navigation or URL creation. [source,typescript jsx] ---- -// Properly generated URL to *Discover* app. Generator code is owned by *Discover* app and available on *Discover*'s plugin contract. -const discoverUrl = discoverUrlGenerator.createUrl({filters, timeRange}); +// Properly generated URL to *Discover* app. Locator code is owned by *Discover* app and available on *Discover*'s plugin contract. +const discoverUrl = await plugins.discover.locator.getUrl({filters, timeRange}); +// or directly execute navigation +await plugins.discover.locator.navigate({filters, timeRange}); ---- -To get a better idea, take a look at *Discover* URL generator {kib-repo}tree/{branch}/src/plugins/discover/public/url_generator.ts[implementation]. +To get a better idea, take a look at *Discover* locator {kib-repo}tree/{branch}/src/plugins/discover/public/locator.ts[implementation]. It allows specifying various **Discover** app state pieces like: index pattern, filters, query, time range and more. -There are two ways to access other's app URL generator in your code: +There are two ways to access locators of other apps: 1. From a plugin contract of a destination app *(preferred)*. -2. Using URL generator service instance on `share` plugin contract (in case an explicit plugin dependency is not possible). +2. Using locator client in `share` plugin (case an explicit plugin dependency is not possible). -In case you want other apps to link to your app, then you should create a URL generator and expose it on your plugin's contract. +In case you want other apps to link to your app, then you should create a locator and expose it on your plugin's contract. [[navigating-between-kibana-apps]] diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index ebab9de66032f..0ee4c09192896 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -70,6 +70,7 @@ yarn kbn watch-bazel - @kbn/apm-utils - @kbn/babel-code-parser - @kbn/babel-preset +- @kbn/cli-dev-mode - @kbn/config - @kbn/config-schema - @kbn/crypto @@ -80,11 +81,14 @@ yarn kbn watch-bazel - @kbn/eslint-plugin-eslint - @kbn/expect - @kbn/i18n +- @kbn/interpreter - @kbn/io-ts-utils - @kbn/legacy-logging - @kbn/logging - @kbn/mapbox-gl - @kbn/monaco +- @kbn/optimizer +- @kbn/plugin-helpers - @kbn/rule-data-utils - @kbn/securitysolution-es-utils - @kbn/securitysolution-hook-utils @@ -99,10 +103,12 @@ yarn kbn watch-bazel - @kbn/securitysolution-utils - @kbn/server-http-tools - @kbn/server-route-repository +- @kbn/spec-to-console - @kbn/std - @kbn/storybook - @kbn/telemetry-utils - @kbn/tinymath +- @kbn/ui-framework - @kbn/ui-shared-deps - @kbn/utility-types - @kbn/utils diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 96326b739422f..231e089950a28 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -191,7 +191,8 @@ so they can properly protect the data within their clusters. |{kib-repo}blob/{branch}/src/plugins/share/README.md[share] -|Replaces the legacy ui/share module for registering share context menus. +|The share plugin contains various utilities for displaying sharing context menu, +generating deep links to other apps, and creating short URLs. |{kib-repo}blob/{branch}/src/plugins/spaces_oss/README.md[spacesOss] diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index d3d76079cdc2a..63d791db452d0 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -8,6 +8,7 @@ ```typescript readonly links: { + readonly settings: string; readonly canvas: { readonly guide: string; }; @@ -106,6 +107,7 @@ readonly links: { }; readonly search: { readonly sessions: string; + readonly sessionLimits: string; }; readonly indexPatterns: { readonly introduction: string; @@ -116,6 +118,7 @@ readonly links: { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 34279cef198bf..947eece498130 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
} | | diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index f341a7cd9315f..a13438ff48e0b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -98,6 +98,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OverlayStart](./kibana-plugin-core-public.overlaystart.md) | | | [Plugin](./kibana-plugin-core-public.plugin.md) | The interface that should be returned by a PluginInitializer. | | [PluginInitializerContext](./kibana-plugin-core-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | +| [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) | This interface is a very simple wrapper for SavedObjects resolved from the server with the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md). | | [SavedObject](./kibana-plugin-core-public.savedobject.md) | | | [SavedObjectAttributes](./kibana-plugin-core-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | | [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) | | @@ -126,6 +127,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsImportUnknownError](./kibana-plugin-core-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | | [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-core-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | | [SavedObjectsMigrationVersion](./kibana-plugin-core-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) | | | [SavedObjectsStart](./kibana-plugin-core-public.savedobjectsstart.md) | | | [SavedObjectsUpdateOptions](./kibana-plugin-core-public.savedobjectsupdateoptions.md) | | | [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md new file mode 100644 index 0000000000000..415681b2bb0d3 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [aliasTargetId](./kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md) + +## ResolvedSimpleSavedObject.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: SavedObjectsResolveResponse['aliasTargetId']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md new file mode 100644 index 0000000000000..43727d86296a4 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) + +## ResolvedSimpleSavedObject interface + +This interface is a very simple wrapper for SavedObjects resolved from the server with the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md). + +Signature: + +```typescript +export interface ResolvedSimpleSavedObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md) | SavedObjectsResolveResponse['aliasTargetId'] | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | +| [outcome](./kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md) | SavedObjectsResolveResponse['outcome'] | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | +| [savedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md) | SimpleSavedObject<T> | The saved object that was found. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md new file mode 100644 index 0000000000000..ceeef7706cc0f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [outcome](./kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md) + +## ResolvedSimpleSavedObject.outcome property + +The outcome for a successful `resolve` call is one of the following values: + +\* `'exactMatch'` -- One document exactly matched the given ID. \* `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. \* `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + +Signature: + +```typescript +outcome: SavedObjectsResolveResponse['outcome']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md new file mode 100644 index 0000000000000..c05e8801768c9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [savedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md) + +## ResolvedSimpleSavedObject.savedObject property + +The saved object that was found. + +Signature: + +```typescript +savedObject: SimpleSavedObject; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.md index 9404927f94957..26f472b741268 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -19,7 +19,7 @@ export interface SavedObject | [error](./kibana-plugin-core-public.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-public.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | -| [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | +| [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | string[] | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with namespaceType: 'agnostic'. | | [originId](./kibana-plugin-core-public.savedobject.originid.md) | string | The ID of the saved object this originated from. This is set if this object's id was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. | | [references](./kibana-plugin-core-public.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-public.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md index 257df45934506..3418b964ab2d7 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md @@ -4,7 +4,7 @@ ## SavedObject.namespaces property -Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. +Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md index 96bbeae346b2e..aacda031003c6 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md @@ -26,6 +26,7 @@ The constructor for this class is marked as internal. Third-party code should no | [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType<SavedObjectsApi['delete']> | Deletes an object | | [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <T = unknown, A = unknown>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T, unknown>> | Search for objects | | [get](./kibana-plugin-core-public.savedobjectsclient.get.md) | | <T = unknown>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | +| [resolve](./kibana-plugin-core-public.savedobjectsclient.resolve.md) | | <T = unknown>(type: string, id: string) => Promise<ResolvedSimpleSavedObject<T>> | Resolves a single object | ## Methods diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.resolve.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.resolve.md new file mode 100644 index 0000000000000..15fb1f3e9ac22 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.resolve.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) > [resolve](./kibana-plugin-core-public.savedobjectsclient.resolve.md) + +## SavedObjectsClient.resolve property + +Resolves a single object + +Signature: + +```typescript +resolve: (type: string, id: string) => Promise>; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md new file mode 100644 index 0000000000000..02055da686880 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [aliasTargetId](./kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md) + +## SavedObjectsResolveResponse.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md new file mode 100644 index 0000000000000..4345f2949d48e --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) + +## SavedObjectsResolveResponse interface + + +Signature: + +```typescript +export interface SavedObjectsResolveResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | +| [outcome](./kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | +| [saved\_object](./kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | The saved object that was found. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md new file mode 100644 index 0000000000000..ff4367d804e5d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [outcome](./kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md) + +## SavedObjectsResolveResponse.outcome property + +The outcome for a successful `resolve` call is one of the following values: + +\* `'exactMatch'` -- One document exactly matched the given ID. \* `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. \* `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + +Signature: + +```typescript +outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md new file mode 100644 index 0000000000000..d8a74d766d582 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [saved\_object](./kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md) + +## SavedObjectsResolveResponse.saved\_object property + +The saved object that was found. + +Signature: + +```typescript +saved_object: SavedObject; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md index 8fb005421e870..c73a3a200cc24 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `SimpleSavedObject` class Signature: ```typescript -constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObjectType); +constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, }: SavedObjectType); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(client: SavedObjectsClientContract, { id, type, version, attributes, | Parameter | Type | Description | | --- | --- | --- | | client | SavedObjectsClientContract | | -| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, } | SavedObjectType<T> | | +| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, } | SavedObjectType<T> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md index 35264a3a4cf0c..e15a4d4ea6d09 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md @@ -18,7 +18,7 @@ export declare class SimpleSavedObject | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | +| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | ## Properties @@ -30,6 +30,7 @@ export declare class SimpleSavedObject | [error](./kibana-plugin-core-public.simplesavedobject.error.md) | | SavedObjectType<T>['error'] | | | [id](./kibana-plugin-core-public.simplesavedobject.id.md) | | SavedObjectType<T>['id'] | | | [migrationVersion](./kibana-plugin-core-public.simplesavedobject.migrationversion.md) | | SavedObjectType<T>['migrationVersion'] | | +| [namespaces](./kibana-plugin-core-public.simplesavedobject.namespaces.md) | | SavedObjectType<T>['namespaces'] | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with namespaceType: 'agnostic'. | | [references](./kibana-plugin-core-public.simplesavedobject.references.md) | | SavedObjectType<T>['references'] | | | [type](./kibana-plugin-core-public.simplesavedobject.type.md) | | SavedObjectType<T>['type'] | | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.namespaces.md new file mode 100644 index 0000000000000..7fb0a4e3a717a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.namespaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SimpleSavedObject](./kibana-plugin-core-public.simplesavedobject.md) > [namespaces](./kibana-plugin-core-public.simplesavedobject.namespaces.md) + +## SimpleSavedObject.namespaces property + +Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`. + +Signature: + +```typescript +namespaces: SavedObjectType['namespaces']; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md b/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md new file mode 100644 index 0000000000000..217066481d33c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CspConfig](./kibana-plugin-core-server.cspconfig.md) > ["\#private"](./kibana-plugin-core-server.cspconfig.__private_.md) + +## CspConfig."\#private" property + +Signature: + +```typescript +#private; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.cspconfig.md b/docs/development/core/server/kibana-plugin-core-server.cspconfig.md index 9f4f3211ea2b1..0337a1f4d3301 100644 --- a/docs/development/core/server/kibana-plugin-core-server.cspconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.cspconfig.md @@ -20,6 +20,7 @@ The constructor for this class is marked as internal. Third-party code should no | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| ["\#private"](./kibana-plugin-core-server.cspconfig.__private_.md) | | | | | [DEFAULT](./kibana-plugin-core-server.cspconfig.default.md) | static | CspConfig | | | [disableEmbedding](./kibana-plugin-core-server.cspconfig.disableembedding.md) | | boolean | | | [header](./kibana-plugin-core-server.cspconfig.header.md) | | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.md index 07172487e6fde..4c62b359b284d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -19,7 +19,7 @@ export interface SavedObject | [error](./kibana-plugin-core-server.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-server.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | -| [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | +| [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | string[] | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with namespaceType: 'agnostic'. | | [originId](./kibana-plugin-core-server.savedobject.originid.md) | string | The ID of the saved object this originated from. This is set if this object's id was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. | | [references](./kibana-plugin-core-server.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-server.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md index 2a555db01df3b..3c2909486219b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md @@ -4,7 +4,7 @@ ## SavedObject.namespaces property -Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. +Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md index 3db8bbadfbd6b..4d094ecde7a96 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md @@ -6,7 +6,7 @@ Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). -Note: this can only be used for multi-namespace object types. +\* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including the "All spaces" identifier (`'*'`). \* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. \* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 6fc01212a2e41..463c3fe81b702 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -18,7 +18,7 @@ export interface SavedObjectsBulkCreateObject | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | -| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | +| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).\* For shareable object types (registered with namespaceType: 'multiple'): this option can be used to specify one or more spaces, including the "All spaces" identifier ('*'). \* For isolated object types (registered with namespaceType: 'single' or namespaceType: 'multiple-isolated'): this option can only be used to specify a single space, and the "All spaces" identifier ('*') is not allowed. \* For global object types (registered with namespaceType: 'agnostic'): this option cannot be used. | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [references](./kibana-plugin-core-server.savedobjectsbulkcreateobject.references.md) | SavedObjectReference[] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md index 262b0997cb905..43489b8d2e8a2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md @@ -6,7 +6,7 @@ Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). -Note: this can only be used for multi-namespace object types. +\* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including the "All spaces" identifier (`'*'`). \* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. \* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index 1805f389d4e7f..7eaa9c51f5c82 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -17,7 +17,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | -| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | +| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).\* For shareable object types (registered with namespaceType: 'multiple'): this option can be used to specify one or more spaces, including the "All spaces" identifier ('*'). \* For isolated object types (registered with namespaceType: 'single' or namespaceType: 'multiple-isolated'): this option can only be used to specify a single space, and the "All spaces" identifier ('*') is not allowed. \* For global object types (registered with namespaceType: 'agnostic'): this option cannot be used. | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [overwrite](./kibana-plugin-core-server.savedobjectscreateoptions.overwrite.md) | boolean | Overwrite existing documents (defaults to false) | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index ffcf15dbc80c7..8a2504ec7adcc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -17,5 +17,5 @@ export interface SavedObjectsResolveResponse | --- | --- | --- | | [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | | [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | -| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | +| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | The saved object that was found. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md index c184312675f75..c7748a2f97025 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md @@ -4,6 +4,8 @@ ## SavedObjectsResolveResponse.saved\_object property +The saved object that was found. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md index 143cd397c40ae..bf08ca1682f3b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md @@ -24,5 +24,7 @@ set(status$: Observable): void; ## Remarks +The first emission from this Observable should occur within 30s, else this plugin's status will fallback to `unavailable` until the first emission. + See the [StatusServiceSetup.derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) API for leveraging the default status calculation that is provided by Core. diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index 54b5a33ccf682..80c321ce6b320 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -13,11 +13,11 @@ esFilters: { FILTERS: typeof FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isPhraseFilter: (filter: any) => filter is import("../common").PhraseFilter; isExistsFilter: (filter: any) => filter is import("../common").ExistsFilter; isPhrasesFilter: (filter: any) => filter is import("../common").PhrasesFilter; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md index 2cde2b7455585..332114e637586 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md @@ -10,6 +10,6 @@ esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md index 2430e6a93bd2b..0bc9c0c12fc3a 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md @@ -10,7 +10,7 @@ esQuery: { buildEsQuery: typeof buildEsQuery; getEsQueryConfig: typeof getEsQueryConfig; - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md deleted file mode 100644 index 792bee44f96a8..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.fields.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) > [fields](./kibana-plugin-plugins-data-public.iindexpattern.fields.md) - -## IIndexPattern.fields property - -Signature: - -```typescript -fields: IFieldType[]; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md deleted file mode 100644 index 917a80975df6c..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.id.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) > [id](./kibana-plugin-plugins-data-public.iindexpattern.id.md) - -## IIndexPattern.id property - -Signature: - -```typescript -id?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md index bf7f88ab37039..ec29ef81a6e69 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md @@ -12,7 +12,7 @@ Signature: ```typescript -export interface IIndexPattern +export interface IIndexPattern extends IndexPatternBase ``` ## Properties @@ -20,9 +20,7 @@ export interface IIndexPattern | Property | Type | Description | | --- | --- | --- | | [fieldFormatMap](./kibana-plugin-plugins-data-public.iindexpattern.fieldformatmap.md) | Record<string, SerializedFieldFormat<unknown> | undefined> | | -| [fields](./kibana-plugin-plugins-data-public.iindexpattern.fields.md) | IFieldType[] | | | [getFormatterForField](./kibana-plugin-plugins-data-public.iindexpattern.getformatterforfield.md) | (field: IndexPatternField | IndexPatternField['spec'] | IFieldType) => FieldFormat | Look up a formatter for a given field | -| [id](./kibana-plugin-plugins-data-public.iindexpattern.id.md) | string | | | [timeFieldName](./kibana-plugin-plugins-data-public.iindexpattern.timefieldname.md) | string | | | [title](./kibana-plugin-plugins-data-public.iindexpattern.title.md) | string | | | [type](./kibana-plugin-plugins-data-public.iindexpattern.type.md) | string | Type is used for identifying rollup indices, otherwise left undefined | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md new file mode 100644 index 0000000000000..d649212ae0547 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IKibanaSearchResponse](./kibana-plugin-plugins-data-public.ikibanasearchresponse.md) > [isRestored](./kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md) + +## IKibanaSearchResponse.isRestored property + +Indicates whether the results returned are from the async-search index + +Signature: + +```typescript +isRestored?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md index 1d3e0c08dfc18..c7046902dac72 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ikibanasearchresponse.md @@ -16,6 +16,7 @@ export interface IKibanaSearchResponse | --- | --- | --- | | [id](./kibana-plugin-plugins-data-public.ikibanasearchresponse.id.md) | string | Some responses may contain a unique id to identify the request this response came from. | | [isPartial](./kibana-plugin-plugins-data-public.ikibanasearchresponse.ispartial.md) | boolean | Indicates whether the results returned are complete or partial | +| [isRestored](./kibana-plugin-plugins-data-public.ikibanasearchresponse.isrestored.md) | boolean | Indicates whether the results returned are from the async-search index | | [isRunning](./kibana-plugin-plugins-data-public.ikibanasearchresponse.isrunning.md) | boolean | Indicates whether search is still in flight | | [loaded](./kibana-plugin-plugins-data-public.ikibanasearchresponse.loaded.md) | number | If relevant to the search strategy, return a loaded number that represents how progress is indicated. | | [rawResponse](./kibana-plugin-plugins-data-public.ikibanasearchresponse.rawresponse.md) | RawResponse | The raw response returned by the internal search method (usually the raw ES response) | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md new file mode 100644 index 0000000000000..552d131984517 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md) + +## INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE variable + +\* + +Signature: + +```typescript +INDEX_PATTERN_SAVED_OBJECT_TYPE = "index-pattern" +``` 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 7c023e756ebd5..65c4601d5faec 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 @@ -118,6 +118,7 @@ | [fieldFormats](./kibana-plugin-plugins-data-public.fieldformats.md) | | | [fieldList](./kibana-plugin-plugins-data-public.fieldlist.md) | | | [getKbnTypeNames](./kibana-plugin-plugins-data-public.getkbntypenames.md) | Get the esTypes known by all kbnFieldTypes {Array} | +| [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md) | \* | | [indexPatterns](./kibana-plugin-plugins-data-public.indexpatterns.md) | | | [injectSearchSourceReferences](./kibana-plugin-plugins-data-public.injectsearchsourcereferences.md) | | | [isCompleteResponse](./kibana-plugin-plugins-data-public.iscompleteresponse.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md index d7e80d94db4e6..d009cad9ec601 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md @@ -11,11 +11,11 @@ esFilters: { buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; buildCustomFilter: typeof buildCustomFilter; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; buildFilter: typeof buildFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isFilterDisabled: (filter: import("../common").Filter) => boolean; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md index 4b96d8af756f3..fce25a899de8e 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md @@ -10,6 +10,6 @@ esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md index ac9be23bc6b6f..68507f3fb9b81 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md @@ -8,7 +8,7 @@ ```typescript esQuery: { - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md new file mode 100644 index 0000000000000..34f76d4ab13b1 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md) + +## INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE variable + +\* + +Signature: + +```typescript +INDEX_PATTERN_SAVED_OBJECT_TYPE = "index-pattern" +``` 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 b1745b298e27e..ab14abdd74e87 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 @@ -13,6 +13,7 @@ | [IndexPatternsFetcher](./kibana-plugin-plugins-data-server.indexpatternsfetcher.md) | | | [IndexPatternsService](./kibana-plugin-plugins-data-server.indexpatternsservice.md) | | | [IndexPatternsServiceProvider](./kibana-plugin-plugins-data-server.indexpatternsserviceprovider.md) | | +| [NoSearchIdInSessionError](./kibana-plugin-plugins-data-server.nosearchidinsessionerror.md) | | | [OptionedParamType](./kibana-plugin-plugins-data-server.optionedparamtype.md) | | | [Plugin](./kibana-plugin-plugins-data-server.plugin.md) | | @@ -82,6 +83,7 @@ | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | | [exporters](./kibana-plugin-plugins-data-server.exporters.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | +| [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md) | \* | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | | [mergeCapabilitiesWithFields](./kibana-plugin-plugins-data-server.mergecapabilitieswithfields.md) | | | [search](./kibana-plugin-plugins-data-server.search.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md new file mode 100644 index 0000000000000..e48a1c98f8578 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [NoSearchIdInSessionError](./kibana-plugin-plugins-data-server.nosearchidinsessionerror.md) > [(constructor)](./kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md) + +## NoSearchIdInSessionError.(constructor) + +Constructs a new instance of the `NoSearchIdInSessionError` class + +Signature: + +```typescript +constructor(); +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror.md new file mode 100644 index 0000000000000..707739f845cd1 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.nosearchidinsessionerror.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [NoSearchIdInSessionError](./kibana-plugin-plugins-data-server.nosearchidinsessionerror.md) + +## NoSearchIdInSessionError class + +Signature: + +```typescript +export declare class NoSearchIdInSessionError extends KbnError +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)()](./kibana-plugin-plugins-data-server.nosearchidinsessionerror._constructor_.md) | | Constructs a new instance of the NoSearchIdInSessionError class | + diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md index b875b1fce4288..444132024596e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md @@ -36,6 +36,7 @@ | [isSavedObjectEmbeddableInput(input)](./kibana-plugin-plugins-embeddable-public.issavedobjectembeddableinput.md) | | | [openAddPanelFlyout(options)](./kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-embeddable-public.plugin.md) | | +| [useEmbeddableFactory({ input, factory, onInputUpdated, })](./kibana-plugin-plugins-embeddable-public.useembeddablefactory.md) | | ## Interfaces diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md new file mode 100644 index 0000000000000..9af20cacc2cee --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [useEmbeddableFactory](./kibana-plugin-plugins-embeddable-public.useembeddablefactory.md) + +## useEmbeddableFactory() function + +Signature: + +```typescript +export declare function useEmbeddableFactory({ input, factory, onInputUpdated, }: EmbeddableRendererWithFactory): readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| { input, factory, onInputUpdated, } | EmbeddableRendererWithFactory<I> | | + +Returns: + +`readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]` + diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 65b600d4b7281..3d3d7aeb2d777 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -43,6 +43,10 @@ a| <> | Send a message to a Slack channel or user. +a| <> + +| Create an incident in Swimlane. + a| <> | Send a request to a web service. diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 853180ec816e9..66a23ee189ae1 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -482,6 +482,9 @@ of buckets to try to represent. [[visualization-visualize-chartslibrary]]`visualization:visualize:legacyChartsLibrary`:: Enables the legacy charts library for aggregation-based area, line, and bar charts in *Visualize*. +[[visualization-visualize-pieChartslibrary]]`visualization:visualize:legacyPieChartsLibrary`:: +Enables the legacy charts library for aggregation-based pie charts in *Visualize*. + [[visualization-colormapping]]`visualization:colorMapping`:: **This setting is deprecated and will not be supported as of 8.0.** Maps values to specific colors in charts using the *Compatibility* palette. diff --git a/docs/management/connectors/action-types/swimlane.asciidoc b/docs/management/connectors/action-types/swimlane.asciidoc new file mode 100644 index 0000000000000..88447bb496a86 --- /dev/null +++ b/docs/management/connectors/action-types/swimlane.asciidoc @@ -0,0 +1,105 @@ +[role="xpack"] +[[swimlane-action-type]] +=== Swimlane connector and action +++++ +Swimlane +++++ + +The Swimlane connector uses the https://swimlane.com/knowledge-center/docs/developer-guide/rest-api/[Swimlane REST API] to create Swimlane records. + +[float] +[[swimlane-connector-configuration]] +==== Connector configuration + +Swimlane connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: Swimlane instance URL. +Application ID:: Swimlane application ID. +API token:: Swimlane API authentication token for HTTP Basic authentication. + +[float] +[[Preconfigured-swimlane-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-swimlane: + name: preconfigured-swimlane-connector-type + actionTypeId: .swimlane + config: + apiUrl: https://elastic.swimlaneurl.us + appId: app-id + mappings: + alertIdConfig: + fieldType: text + id: agp4s + key: alert-id + name: Alert ID + caseIdConfig: + fieldType: text + id: ae1mi + key: case-id + name: Case ID + caseNameConfig: + fieldType: text + id: anxnr + key: case-name + name: Case Name + commentsConfig: + fieldType: comments + id: au18d + key: comments + name: Comments + descriptionConfig: + fieldType: text + id: ae1gd + key: description + name: Description + ruleNameConfig: + fieldType: text + id: avfsl + key: rule-name + name: Rule Name + severityConfig: + fieldType: text + id: a71ik + key: severity + name: severity + secrets: + apiToken: tokenkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. +`appId`:: A key that corresponds to *Application ID*. + +Secrets defines sensitive information for the connector type. + +`apiToken`:: A string that corresponds to *API Token*. Should be stored in the <>. + +[float] +[[define-swimlane-ui]] +==== Define connector in Stack Management + +Define Swimlane connector properties. + +[role="screenshot"] +image::management/connectors/images/swimlane-connector.png[Swimlane connector] + +Test Swimlane action parameters. + +[role="screenshot"] +image::management/connectors/images/swimlane-params-test.png[Swimlane params test] + +[float] +[[swimlane-action-configuration]] +==== Action configuration + +Swimlane actions have the following configuration properties. + +Comments:: Additional information for the client, such as how to troubleshoot the issue. +Severity:: The severity of the incident. + +NOTE: Alert ID and Rule Name are filled automatically. Specifically, Alert ID is set to `{{alert.id}}` and Rule Name to `{{rule.name}}`. \ No newline at end of file diff --git a/docs/management/connectors/images/swimlane-connector.png b/docs/management/connectors/images/swimlane-connector.png new file mode 100644 index 0000000000000..520c35d00381b Binary files /dev/null and b/docs/management/connectors/images/swimlane-connector.png differ diff --git a/docs/management/connectors/images/swimlane-params-test.png b/docs/management/connectors/images/swimlane-params-test.png new file mode 100644 index 0000000000000..c0e02c2c7b18f Binary files /dev/null and b/docs/management/connectors/images/swimlane-params-test.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index ea4fa46d3e808..033b1c3ac150e 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[] include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] +include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] include::pre-configured-connectors.asciidoc[] diff --git a/docs/maps/connect-to-ems.asciidoc b/docs/maps/connect-to-ems.asciidoc index a54da6597b9b0..1db9dd5ee1123 100644 --- a/docs/maps/connect-to-ems.asciidoc +++ b/docs/maps/connect-to-ems.asciidoc @@ -34,7 +34,6 @@ To disable EMS, change your <> file. . Set `map.includeElasticMapsService` to `false` to turn off the EMS connection. . Set `map.tilemap.url` to the URL of your tile server. This configures the default tile layer of Maps. -. (Optional) Set `map.regionmap` to the vector shapes of the administrative boundaries that you want to use. [float] [id=elastic-maps-server] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 71f141d1ed5d6..d1d283ca60fbb 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -69,7 +69,7 @@ You can configure the following settings in the `kibana.yml` file. -- xpack.actions.customHostSettings: - url: smtp://mail.example.com:465 - tls: + ssl: verificationMode: 'full' certificateAuthoritiesFiles: [ 'one.crt' ] certificateAuthoritiesData: | @@ -79,7 +79,7 @@ xpack.actions.customHostSettings: smtp: requireTLS: true - url: https://webhook.example.com - tls: + ssl: // legacy rejectUnauthorized: false verificationMode: 'none' @@ -97,8 +97,8 @@ xpack.actions.customHostSettings: server, and the `https` URLs are used for actions which use `https` to connect to services. + + - Entries with `https` URLs can use the `tls` options, and entries with `smtp` - URLs can use both the `tls` and `smtp` options. + + Entries with `https` URLs can use the `ssl` options, and entries with `smtp` + URLs can use both the `ssl` and `smtp` options. + + No other URL values should be part of this URL, including paths, query strings, and authentication information. When an http or smtp request @@ -117,24 +117,24 @@ xpack.actions.customHostSettings: The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. | `xpack.actions.customHostSettings[n]` -`.tls.rejectUnauthorized` {ess-icon} - | Deprecated. Use <> instead. A boolean value indicating whether to bypass server certificate validation. +`.ssl.rejectUnauthorized` {ess-icon} + | Deprecated. Use <> instead. A boolean value indicating whether to bypass server certificate validation. Overrides the general `xpack.actions.rejectUnauthorized` configuration for requests made for this hostname/port. |[[action-config-custom-host-verification-mode]] `xpack.actions.customHostSettings[n]` -`.tls.verificationMode` +`.ssl.verificationMode` | Controls the verification of the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the host server. Valid values are `full`, `certificate`, and `none`. - Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. Overrides the general `xpack.actions.tls.verificationMode` configuration + Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. Overrides the general `xpack.actions.ssl.verificationMode` configuration for requests made for this hostname/port. | `xpack.actions.customHostSettings[n]` -`.tls.certificateAuthoritiesFiles` +`.ssl.certificateAuthoritiesFiles` | A file name or list of file names of PEM-encoded certificate files to use to validate the server. | `xpack.actions.customHostSettings[n]` -`.tls.certificateAuthoritiesData` {ess-icon} +`.ssl.certificateAuthoritiesData` {ess-icon} | The contents of a PEM-encoded certificate file, or multiple files appended into a single string. This configuration can be used for environments where the files cannot be made available. @@ -165,28 +165,28 @@ xpack.actions.customHostSettings: a|`xpack.actions.` `proxyRejectUnauthorizedCertificates` {ess-icon} - | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for the proxy, if using a proxy for actions. Default: `true`. + | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for the proxy, if using a proxy for actions. Default: `true`. |[[action-config-proxy-verification-mode]] `xpack.actions[n]` -`.tls.proxyVerificationMode` {ess-icon} +`.ssl.proxyVerificationMode` {ess-icon} | Controls the verification for the proxy server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the proxy server. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. | `xpack.actions.rejectUnauthorized` {ess-icon} - | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for actions. Default: `true`. + + | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for actions. Default: `true`. + + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting - `xpack.actions.customHostSettings` to set TLS options for specific servers. + `xpack.actions.customHostSettings` to set SSL options for specific servers. |[[action-config-verification-mode]] `xpack.actions[n]` -`.tls.verificationMode` {ess-icon} +`.ssl.verificationMode` {ess-icon} | Controls the verification for the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection for actions. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. + + - As an alternative to setting `xpack.actions.tls.verificationMode`, you can use the setting - `xpack.actions.customHostSettings` to set TLS options for specific servers. + As an alternative to setting `xpack.actions.ssl.verificationMode`, you can use the setting + `xpack.actions.customHostSettings` to set SSL options for specific servers. diff --git a/docs/settings/task-manager-settings.asciidoc b/docs/settings/task-manager-settings.asciidoc index 87f5b700870eb..7f4dbb3a96e6b 100644 --- a/docs/settings/task-manager-settings.asciidoc +++ b/docs/settings/task-manager-settings.asciidoc @@ -29,7 +29,13 @@ Task Manager runs background tasks by polling for work on an interval. You can | The maximum number of tasks that this Kibana instance will run simultaneously. Defaults to 10. Starting in 8.0, it will not be possible to set the value greater than 100. - | `xpack.task_manager.monitored_stats_warn_delayed_task_start_in_seconds` + | `xpack.task_manager.` + `monitored_stats_health_verbose_log.enabled` + | This flag will enable automatic warn and error logging if task manager self detects a performance issue, such as the time between when a task is scheduled to execute and when it actually executes. Defaults to false. + + | `xpack.task_manager.` + `monitored_stats_health_verbose_log.` + `warn_delayed_task_start_in_seconds` | The amount of seconds we allow a task to delay before printing a warning server log. Defaults to 60. |=== diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index c3c29adcea18f..696b2f042057d 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -36,11 +36,57 @@ Set to `false` to disable Console. *Default: `true`* <>. | `csp.rules:` - | A https://w3c.github.io/webappsec-csp/[content-security-policy] template + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] +A https://w3c.github.io/webappsec-csp/[Content Security Policy] template that disables certain unnecessary and potentially insecure capabilities in the browser. It is strongly recommended that you keep the default CSP rules that ship with {kib}. +| `csp.script_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src[Content Security Policy `script-src` directive]. + +| `csp.worker_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/worker-src[Content Security Policy `worker-src` directive]. + +| `csp.style_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src[Content Security Policy `style-src` directive]. + +| `csp.connect_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src[Content Security Policy `connect-src` directive]. + +| `csp.default_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src[Content Security Policy `default-src` directive]. + +| `csp.font_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/font-src[Content Security Policy `font-src` directive]. + +| `csp.frame_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src[Content Security Policy `frame-src` directive]. + +| `csp.img_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src[Content Security Policy `img-src` directive]. + +| `csp.frame_ancestors:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors[Content Security Policy `frame-ancestors` directive]. + +|=== + +[NOTE] +============ +The `frame-ancestors` directive can also be configured by using +<>. In that case, that takes precedence and any values in `csp.frame_ancestors` +are ignored. +============ + +[cols="2*<"] +|=== + +| `csp.report_uri:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri[Content Security Policy `report-uri` directive]. + +| `csp.report_to:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-to[Content Security Policy `report-to` directive]. + |[[csp-strict]] `csp.strict:` | Blocks {kib} access to any browser that does not enforce even rudimentary CSP rules. In practice, this disables @@ -322,8 +368,7 @@ The time interval policy will rotate the log file every given interval of time. | [[regionmap-ES-map]] `map.includeElasticMapsService:` {ess-icon} | Set to `false` to disable connections to Elastic Maps Service. -When `includeElasticMapsService` is turned off, only the vector layers configured by <> -and the tile layer configured by <> are available in <>. *Default: `true`* +When `includeElasticMapsService` is turned off, only tile layer configured by <> is available in <>. *Default: `true`* | `map.emsUrl:` | Specifies the URL of a self hosted <> @@ -333,7 +378,8 @@ and the tile layer configured by <> are availabl requests through the {kib} server. *Default: `false`* | [[regionmap-settings]] `map.regionmap:` {ess-icon} - | Specifies additional vector layers for + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] + Specifies additional vector layers for use in <> visualizations. Each layer object points to an external vector file that contains a geojson FeatureCollection. The file must use the @@ -347,7 +393,6 @@ The following example shows a valid region map configuration. [source,text] -- map.regionmap: - includeElasticMapsService: false layers: - name: "Departments of France" url: "http://my.cors.enabled.server.org/france_departements.geojson" @@ -363,10 +408,12 @@ map.regionmap: |=== | [[regionmap-attribution]] `map.regionmap.layers[].attribution:` {ess-icon} - | Optional. References the originating source of the geojson file. + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] + Optional. References the originating source of the geojson file. | [[regionmap-fields]] `map.regionmap.layers[].fields[]:` {ess-icon} - | Mandatory. Each layer + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] + Mandatory. Each layer can contain multiple fields to indicate what properties from the geojson features you wish to expose. The following shows how to define multiple properties: @@ -376,7 +423,6 @@ properties: [source,text] -- map.regionmap: - includeElasticMapsService: false layers: - name: "Departments of France" url: "http://my.cors.enabled.server.org/france_departements.geojson" @@ -392,11 +438,13 @@ map.regionmap: |=== | [[regionmap-field-description]] `map.regionmap.layers[].fields[].description:` {ess-icon} - | Mandatory. The human readable text that is shown under the Options tab when + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] + Mandatory. The human readable text that is shown under the Options tab when building the Region Map visualization. | [[regionmap-field-name]] `map.regionmap.layers[].fields[].name:` {ess-icon} - | Mandatory. + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] + Mandatory. This value is used to do an inner-join between the document stored in {es} and the geojson file. For example, if the field in the geojson is called `Location` and has city names, there must be a field in {es} @@ -404,12 +452,12 @@ that holds the same values that {kib} can then use to lookup for the geoshape data. | [[regionmap-name]] `map.regionmap.layers[].name:` {ess-icon} - | Mandatory. A description of -the map being provided. + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] + Mandatory. A description of the map being provided. | [[regionmap-url]] `map.regionmap.layers[].url:` {ess-icon} - | Mandatory. The location of the -geojson file as provided by a webserver. + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] + Mandatory. The location of the geojson file as provided by a webserver. | [[tilemap-settings]] `map.tilemap.options.attribution:` {ess-icon} | The map attribution string. @@ -538,8 +586,7 @@ a|`server.securityResponseHeaders:` is used in all responses to the client from the {kib} server, and specifies what value is used. Allowed values are any text value or `null`. To disable, set to `null`. *Default:* `null` -[[server-securityResponseHeaders-disableEmbedding]] -a|`server.securityResponseHeaders:` +|[[server-securityResponseHeaders-disableEmbedding]]`server.securityResponseHeaders:` `disableEmbedding:` | Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy[`Content-Security-Policy`] and https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options[`X-Frame-Options`] headers are configured to disable embedding diff --git a/docs/setup/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index fdcd71791ad3a..947043b21ef50 100644 --- a/docs/setup/upgrade/upgrade-migrations.asciidoc +++ b/docs/setup/upgrade/upgrade-migrations.asciidoc @@ -55,22 +55,55 @@ This section highlights common causes of {kib} upgrade failures and how to preve There is a known issue in v7.12.0 for users who tried the fleet beta. Upgrade migrations fail because of a large number of documents in the `.kibana` index. This can cause Kibana to log errors like: -> Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [receive_timeout_transport_exception]: [instance-0000000002][10.32.1.112:19541][cluster:monitor/task/get] request_id [2648] timed out after [59940ms] -> Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [timeout_exception]: Timed out waiting for completion of [org.elasticsearch.index.reindex.BulkByScrollTask@6a74c54] + +[source,sh] +-------------------------------------------- +Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [receive_timeout_transport_exception]: [instance-0000000002][10.32.1.112:19541][cluster:monitor/task/get] request_id [2648] timed out after [59940ms] + +Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [timeout_exception]: Timed out waiting for completion of [org.elasticsearch.index.reindex.BulkByScrollTask@6a74c54] +-------------------------------------------- See https://github.com/elastic/kibana/issues/95321 for instructions to work around this issue. [float] ===== Corrupt saved objects -We highly recommend testing your {kib} upgrade in a development cluster to discover and remedy problems caused by corrupt documents, especially when there are custom integrations creating saved objects in your environment. Saved objects that were corrupted through manual editing or integrations will cause migration failures with a log message like `Failed to transform document. Transform: index-pattern:7.0.0\n Doc: {...}` or `Unable to migrate the corrupt Saved Object document ...`. Corrupt documents will have to be fixed or deleted before an upgrade migration can succeed. +We highly recommend testing your {kib} upgrade in a development cluster to discover and remedy problems caused by corrupt documents, especially when there are custom integrations creating saved objects in your environment. + +Saved objects that were corrupted through manual editing or integrations will cause migration failures with a log message like `Failed to transform document. Transform: index-pattern:7.0.0\n Doc: {...}` or `Unable to migrate the corrupt Saved Object document ...`. Corrupt documents will have to be fixed or deleted before an upgrade migration can succeed. For example, given the following error message: -> Unable to migrate the corrupt saved object document with _id: 'marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275'. To allow migrations to proceed, please delete this document from the [.kibana_7.12.0_001] index. -The following steps must be followed to allow the upgrade migration to succeed. -Please be aware the Dashboard having ID `e3c5fc71-ac71-4805-bcab-2bcc9cc93275` belonging to the space `marketing_space` will no more be available: -1. Delete the corrupt document with `DELETE .kibana_7.12.0_001/_doc/marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275` -2. Restart {kib} +[source,sh] +-------------------------------------------- +Unable to migrate the corrupt saved object document with _id: 'marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275'. To allow migrations to proceed, please delete this document from the [.kibana_7.12.0_001] index. +-------------------------------------------- + +The following steps must be followed to delete the document that is causing the migration to fail: + +. Remove the write block which the migration system has placed on the previous index: ++ +[source,sh] +-------------------------------------------- +PUT .kibana_7.12.1_001/_settings +{ + "index": { + "blocks.write": false + } +} +-------------------------------------------- + +. Delete the corrupt document: ++ +[source,sh] +-------------------------------------------- +DELETE .kibana_7.12.0_001/_doc/marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275 +-------------------------------------------- + +. Restart {kib}. + +In this example, the Dashboard with ID `e3c5fc71-ac71-4805-bcab-2bcc9cc93275` that belongs to the space `marketing_space` **will no longer be available**. + +Be sure you have a snapshot before you delete the corrupt document. If restoring from a snapshot is not an option, it is recommended to also delete the `temp` and `target` indices the migration created before restarting {kib} and retrying. [float] ===== User defined index templates that causes new `.kibana*` indices to have incompatible settings or mappings diff --git a/docs/siem/index.asciidoc b/docs/siem/index.asciidoc index 18895f0533fd7..05b1ec0b5b797 100644 --- a/docs/siem/index.asciidoc +++ b/docs/siem/index.asciidoc @@ -1,60 +1,164 @@ +[chapter] [role="xpack"] [[xpack-siem]] -= Elastic Security += Elastic Security overview +++++ +Security +++++ -[partintro] --- +https://www.elastic.co/security[Elastic Security] combines SIEM threat detection features with endpoint +prevention and response capabilities in one solution. These analytical and +protection capabilities, leveraged by the speed and extensibility of +Elasticsearch, enable analysts to defend their organization from threats before +damage and loss occur. -Elastic Security combines SIEM threat detection features with endpoint -prevention and response capabilities in one solution, including: +Elastic Security provides the following security benefits and capabilities: -* A detection engine to identify attacks and system misconfiguration +* A detection engine to identify attacks and system misconfigurations * A workspace for event triage and investigations * Interactive visualizations to investigate process relationships -* Embedded case management and automated actions -* Detection of signatureless attacks with prebuilt {ml} anomaly jobs and -detection rules +* Inbuilt case management with automated actions +* Detection of signatureless attacks with prebuilt machine learning anomaly jobs +and detection rules -[role="screenshot"] -image::siem/images/overview-ui.png[Elastic Security in Kibana] - -[float] -== Add data - -Kibana provides step-by-step instructions to help you add data. The -{security-guide}[Security Guide] is a good source for more -detailed information and instructions. - -[float] -=== {Beats} - -https://www.elastic.co/products/beats/auditbeat[{auditbeat}], -https://www.elastic.co/products/beats/filebeat[{filebeat}], -https://www.elastic.co/products/beats/winlogbeat[{winlogbeat}], and -https://www.elastic.co/products/beats/packetbeat[{packetbeat}] -send security events and other data to Elasticsearch. +[discrete] +== Elastic Security components and workflow -The default index patterns for Elastic Security events are `auditbeat-*`, `winlogbeat-*`, -`filebeat-*`, `packetbeat-*`, `endgame-*`, `logs-*`, and `apm-*-transaction*`. To change the default pattern patterns, go to *Stack Management > Advanced Settings > securitySolution:defaultIndex*. +The following diagram provides a comprehensive illustration of the Elastic Security workflow. -[float] -=== Elastic Security endpoint agent - -The agent detects and protects against malware, and ships host and network -events directly to Elastic Security. - -[float] -=== Elastic Common Schema (ECS) for normalizing data - -The {ecs-ref}[Elastic Common Schema (ECS)] defines a common set of fields to be -used for storing event data in Elasticsearch. ECS helps users normalize their -event data to better analyze, visualize, and correlate the data represented in -their events. - -Elastic Security can ingest and normalize events from ECS-compatible data sources. +[role="screenshot"] +image::../siem/images/workflow.png[] + +Here's an overview of the flow and its components: + +* Data is shipped from your hosts to {es} via beat modules and the Elastic https://www.elastic.co/endpoint-security/[Endpoint Security agent integration]. This integration provides capabilities such as collecting events, detecting and preventing {security-guide}/detection-engine-overview.html#malware-prevention[malicious activity], and artifact delivery. The {fleet-guide}/fleet-overview.html[{fleet}] app is used to +install and manage agents and integrations on your hosts. ++ +The Endpoint Security integration ships the following data sets: ++ +*** *Windows*: Process, network, file, DNS, registry, DLL and driver loads, +malware security detections +*** *Linux/macOS*: Process, network, file ++ +* https://www.elastic.co/integrations?solution=security[Beat modules]: {beats} +are lightweight data shippers. Beat modules provide a way of collecting and +parsing specific data sets from common sources, such as cloud and OS events, +logs, and metrics. Common security-related modules are listed {security-guide}/ingest-data.html#enable-beat-modules[here]. +* The {security-app} in {kib} is used to manage the *Detection engine*, +*Cases*, and *Timeline*, as well as administer hosts running Endpoint Security: +** Detection engine: Automatically searches for suspicious host and network +activity via the following: +*** {security-guide}/detection-engine-overview.html#detection-engine-overview[Detection rules]: Periodically search the data +({es} indices) sent from your hosts for suspicious events. When a suspicious +event is discovered, a detection alert is generated. External systems, such as +Slack and email, can be used to send notifications when alerts are generated. +You can create your own rules and make use of our {security-guide}/prebuilt-rules.html[prebuilt ones]. +*** {security-guide}/detections-ui-exceptions.html[Exceptions]: Reduce noise and the number of +false positives. Exceptions are associated with rules and prevent alerts when +an exception's conditions are met. *Value lists* contain source event +values that can be used as part of an exception's conditions. When +Elastic {endpoint-sec} is installed on your hosts, you can add malware exceptions +directly to the endpoint from the Security app. +*** {security-guide}/machine-learning.html#included-jobs[{ml-cap} jobs]: Automatic anomaly detection of host and +network events. Anomaly scores are provided per host and can be used with +detection rules. +** {security-guide}/timelines-ui.html[Timeline]: Workspace for investigating alerts and events. +Timelines use queries and filters to drill down into events related to +a specific incident. Timeline templates are attached to rules and use predefined +queries when alerts are investigated. Timelines can be saved and shared with +others, as well as attached to Cases. +** {security-guide}/cases-overview.html[Cases]: An internal system for opening, tracking, and sharing +security issues directly in the Security app. Cases can be integrated with +external ticketing systems. +** {security-guide}/admin-page-ov.html[Administration]: View and manage hosts running {endpoint-sec}. + +{security-guide}/ingest-data.html[Ingest data to Elastic Security] and {security-guide}/install-endpoint.html[Configure and install the Elastic Endpoint integration] describe how to ship security-related +data to {es}. + + +For more background information, see: + +* https://www.elastic.co/products/elasticsearch[{es}]: A real-time, +distributed storage, search, and analytics engine. {es} excels at indexing +streams of semi-structured data, such as logs or metrics. +* https://www.elastic.co/products/kibana[{kib}]: An open-source analytics and +visualization platform designed to work with {es}. You use {kib} to search, +view, and interact with data stored in {es} indices. You can easily compile +advanced data analysis and visualize your data in a variety of charts, tables, +and maps. + +[discrete] +=== Compatibility with cold tier nodes + +Cold tier is a {ref}/data-tiers.html[data tier] that holds time-series data that is accessed only occasionally. In {stack} version >=7.11.0, {elastic-sec} supports cold tier data for the following {es} indices: + +* Index patterns specified in `securitySolution:defaultIndex` +* Index patterns specified in the definitions of detection rules, except for indicator match rules +* Index patterns specified in the data sources selector on various {security-app} pages + +{elastic-sec} does NOT support cold tier data for the following {es} indices: + +* Index patterns controlled by {elastic-sec}, including signals and list indices +* Index patterns specified in indicator match rules + +Using cold tier data for unsupported indices may result in detection rule timeouts and overall performance degradation. + +[discrete] +[[self-protection]] +==== Elastic Endpoint self-protection + +Self-protection means that {elastic-endpoint} has guards against users and attackers that may try to interfere with its functionality. This protection feature is consistently enhanced to prevent attackers who may attempt to use newer, more sophisticated tactics to interfere with the {elastic-endpoint}. Self-protection is enabled by default when {elastic-endpoint} installs on supported platforms, listed below. + +Self-protection is enabled on the following 64-bit Windows versions: + +* Windows 8.1 +* Windows 10 +* Windows Server 2012 R2 +* Windows Server 2016 +* Windows Server 2019 + +And on the following macOS versions: + +* macOS 10.15 (Catalina) +* macOS 11 (Big Sur) + +NOTE: Other Windows and macOS variants (and all Linux distributions) do not have self-protection. + +For {stack} version >= 7.11.0, self-protection defines the following permissions: + +* Users -- even Administrator/root -- *cannot* delete {elastic-endpoint} files (located at `c:\Program Files\Elastic\Endpoint` on Windows, and `/Library/Elastic/Endpoint` on macOS). +* Users *cannot* terminate the {elastic-endpoint} program or service. +* Administrator/root users *can* read the endpoint's files. On Windows, the easiest way to read Endpoint files is to start an Administrator `cmd.exe` prompt. On macOS, an Administrator can use the `sudo` command. +* Administrator/root users *can* stop the {elastic-agent}'s service. On Windows, run the `sc stop "Elastic Agent"` command. On macOS, run the `sudo launchctl stop elastic-agent` command. + + +[discrete] +[[siem-integration]] +=== Integration with other Elastic products + +You can use {elastic-sec} with other Elastic products and features to help you +identify and investigate suspicious activity: + +* https://www.elastic.co/products/stack/machine-learning[{ml-cap}] +* https://www.elastic.co/products/stack/alerting[Alerting] +* https://www.elastic.co/products/stack/canvas[Canvas] + +[discrete] +[[data-sources]] +=== APM transaction data sources + +By default, {elastic-sec} monitors {apm-app-ref}/apm-getting-started.html[APM] +`apm-*-transaction*` indices. To add additional APM indices, update the +index patterns in the `securitySolution:defaultIndex` setting ({kib} -> Stack Management -> Advanced Settings -> `securitySolution:defaultIndex`). --- +[discrete] +[[ecs-compliant-reqs]] +=== ECS compliance data requirements +The {ecs-ref}[Elastic Common Schema (ECS)] defines a common set of fields used for +storing event data in Elasticsearch. ECS helps users normalize their event data +to better analyze, visualize, and correlate the data represented in their +events. {elastic-sec} supports events and indicator index data from any ECS-compliant data source. -include::siem-ui.asciidoc[] -include::machine-learning.asciidoc[] +IMPORTANT: {elastic-sec} requires {ecs-ref}[ECS-compliant data]. If you use third-party data collectors to ship data to {es}, the data must be mapped to ECS. +{security-guide}/siem-field-reference.html[Elastic Security ECS field reference] lists ECS fields used in {elastic-sec}. diff --git a/docs/user/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index b7b0c749dfe14..e45a77d48da9c 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -1,238 +1,201 @@ [role="xpack"] [[alerting-troubleshooting]] -== Alerting Troubleshooting +== Troubleshooting ++++ Troubleshooting ++++ -This page describes how to resolve common problems you might encounter with Alerting. -If your problem isn’t described here, please review open issues in the following GitHub repositories: - -* https://github.com/elastic/kibana/issues[kibana] (https://github.com/elastic/kibana/issues?q=is%3Aopen+is%3Aissue+label%3AFeature%3AAlerting[Alerting issues]) - -Have a question? Contact us in the https://discuss.elastic.co/[discuss forum]. +The Alerting framework provides many options for diagnosing problems with Rules and Connectors. [float] -[[rules-small-check-interval-run-late]] -=== Rules with small check intervals run late - -*Problem*: - -Rules with a small check interval, such as every two seconds, run later than scheduled. - -*Resolution*: +[[alerting-kibana-log]] +=== Check the {kib} log -Rules run as background tasks at a cadence defined by their *check interval*. -When a Rule *check interval* is smaller than the Task Manager <> the rule will run late. - -Either tweak the <> or increase the *check interval* of the rules in question. - -For more details, see <>. +Rules and connectors log to the Kibana logger with tags of [alerting] and [actions], respectively. Generally, the messages are warnings and errors. In some cases, the error might be a false positive, for example, when a connector is deleted and a rule is running. +[source, txt] +-------------------------------------------------- +server log [11:39:40.389] [error][alerting][alerting][plugins][plugins] Executing Alert "5b6237b0-c6f6-11eb-b0ff-a1a0cbcf29b6" has resulted in Error: Saved object [action/fdbc8610-c6f5-11eb-b0ff-a1a0cbcf29b6] not found +-------------------------------------------------- +Some of the resources, such as saved objects and API keys, may no longer be available or valid, yielding error messages about those missing resources. [float] -[[scheduled-rules-run-late]] -=== Rules run late +[[alerting-kibana-version]] +=== Use the debugging tools +The following debugging tools are available: -*Problem*: +* {kib} versions 7.10 and above +have a <> UI. -Scheduled rules run at an inconsistent cadence, often running late. +* {kib} versions 7.11 and above +include improved Webhook error messages, +better overall debug logging for actions and connectors, +and Task Manager <>. -Actions run long after the status of a rule changes, sending a notification of the change too late. +[float] +[[alerting-managment-detail]] +=== Using rules and connectors list for the current state and finding issues +*Rules and Connectors* in *Stack Management* lists the rules and connectors available in the space you’re currently in. When you click a rule name, you are navigated to the <> for the rule, where you can see currently active alerts. +The start date on this page indicates when a rule is triggered, and for what alerts. In addition, the duration of the condition indicates how long the instance is active. +[role="screenshot"] +image::images/rule-details-alerts-inactive.png[Alerting management details] -*Solution*: +[float] +[[alerting-index-threshold-chart]] +=== Preview the index threshold rule chart -Rules and actions run as background tasks by each {kib} instance at a default rate of ten tasks every three seconds. +When creating or editing an index threshold rule, you see a graph of the data the rule will operate against, from some date in the past until now, updated every 5 seconds. +[role="screenshot"] +image::images/index-threshold-chart.png[Index Threshold chart] -If many rules or actions are scheduled to run at the same time, pending tasks will queue in {es}. Each {kib} instance then polls for pending tasks at a rate of up to ten tasks at a time, at three second intervals. Because rules and actions are backed by tasks, it is possible for pending tasks in the queue to exceed this capacity and run late. +The end date is related to the rule interval (IIRC, 30 “intervals” worth of time). You can use this view to see if the rule is getting the data you expect, and visually compare to the threshold value (a horizontal line in the graph). If the graph does not contain any lines except for the threshold line, then the rule has an issue, for example, no data is available given the specified index and fields or there is a permission error. +Diagnosing these may be difficult - but there may be log messages for error conditions. -For details on diagnosing the underlying causes of such delays, see <>. +[float] +[[alerting-rest-api]] +=== Use the REST APIs -Alerting and action tasks are identified by their type. +There is a rich set of HTTP endpoints to introspect and manage rules and connectors. +One of the http endpoints available for actions is the POST <>. You can use this to “test” an action. For instance, if you have a server log action created, you can execute it via curling the endpoint: +[source, txt] +-------------------------------------------------- +curl -X POST -k \ + -H 'kbn-xsrf: foo' \ + -H 'content-type: application/json' \ + api/actions/connector/a692dc89-15b9-4a3c-9e47-9fb6872e49ce/_execute \ + -d '{"params":{"subject":"hallo","message":"hallo!","to":["me@example.com"]}}' +-------------------------------------------------- -* Alerting tasks always begin with `alerting:`. For example, the `alerting:.index-threshold` tasks back the <>. -* Action tasks always begin with `actions:`. For example, the `actions:.index` tasks back the <>. +experimental[] In addition, there is a command-line client that uses legacy Rules and Connectors APIs, which can be easier to use, but must be updated for the new APIs. +CLI tools to list, create, edit, and delete alerts (rules) and actions (connectors) are available in https://github.com/pmuellr/kbn-action[kbn-action], which you can install as follows: +[source, txt] +-------------------------------------------------- +npm install -g pmuellr/kbn-action +-------------------------------------------------- -When diagnosing issues related to Alerting, focus on the tasks that begin with `alerting:` and `actions:`. +The same REST POST _execute API command will be: +[source, txt] +-------------------------------------------------- +kbn-action execute a692dc89-15b9-4a3c-9e47-9fb6872e49ce ‘{"params":{"subject":"hallo","message":"hallo!","to":["me@example.com"]}}’ +-------------------------------------------------- -For more details on monitoring and diagnosing task execution in Task Manager, see <>. +The result of this http request (and printed to stdout by https://github.com/pmuellr/kbn-action[kbn-action]) will be data returned by the action execution, along with error messages if errors were encountered. [float] -[[connector-tls-settings]] -=== Connectors have TLS errors when executing actions - -*Problem*: +[[alerting-error-banners]] +=== Look for error banners -When executing actions, a connector gets a TLS socket error when connecting to -the server. +The Rule Management and Rule Details pages contain an error banner, which helps to identify the errors for the rules: +[role="screenshot"] +image::images/rules-management-health.png[Rule management page with the errors banner] -*Resolution*: - -Configuration options are available to specialize connections to TLS servers, -including ignoring server certificate validation, and providing certificate -authority data to verify servers using custom certificates. For more details, -see <>. +[role="screenshot"] +image::images/rules-details-health.png[Rule details page with the errors banner] [float] -[[rules-long-execution-time]] -=== Identify long-running rules +[[task-manager-diagnostics]] +=== Task Manager diagnostics -The following query can help you identify rules that are taking a long time to execute and might impact the overall health of your deployment. +Under the hood, Rules and Connectors uses a plugin called Task Manager, which handles the scheduling, execution, and error handling of the tasks. +This means that failure cases in Rules or Connectors will, at times, be revealed by the Task Manager mechanism, rather than the Rules mechanism. -[IMPORTANT] -============================================== -By default, only users with a `superuser` role can query the {kib} event log because it is a system index. To enable additional users to execute this query, assign `read` privileges to the `.kibana-event-log*` index. -============================================== +Task Manager provides a visible status which can be used to diagnose issues and is very well documented <> and <>. +Task Manager uses the `.kibana_task_manager` index, an internal index that contains all the saved objects that represent the tasks in the system. -Query for a list of rule ids, bucketed by their execution times: +[float] +==== Getting from a Rule to its Task +When a rule is created, a task is created, scheduled to run at the interval specified. For example, when a rule is created and configured to check every 5 minutes, then the underlying task will be expected to run every 5 minutes. In practice, after each time the rule runs, the task is scheduled to run again in 5 minutes, rather than being scheduled to run every 5 minutes indefinitely. -[source,console] +If you use the <> to fetch the underlying rule, you’ll get an object like so: +[source, txt] -------------------------------------------------- -GET /.kibana-event-log*/_search { - "size": 0, - "query": { - "bool": { - "filter": [ - { - "range": { - "@timestamp": { - "gte": "now-1d", <1> - "lte": "now" - } - } - }, - { - "term": { - "event.action": { - "value": "execute" - } - } - }, - { - "term": { - "event.provider": { - "value": "alerting" <2> - } - } - } - ] - } + "id": "0a037d60-6b62-11eb-9e0d-85d233e3ee35", + "notify_when": "onActionGroupChange", + "params": { + "aggType": "avg", }, - "runtime_mappings": { <3> - "event.duration_in_seconds": { - "type": "double", - "script": { - "source": "emit(doc['event.duration'].value / 1E9)" - } - } + "consumer": "alerts", + "rule_type_id": "test.rule.type", + "schedule": { + "interval": "1m" }, - "aggs": { - "ruleIdsByExecutionDuration": { - "histogram": { - "field": "event.duration_in_seconds", - "min_doc_count": 1, - "interval": 1 <4> - }, - "aggs": { - "ruleId": { - "nested": { - "path": "kibana.saved_objects" - }, - "aggs": { - "ruleId": { - "terms": { - "field": "kibana.saved_objects.id", - "size": 10 <5> - } - } - } - } - } - } + "actions": [], + "tags": [], + "name": "test rule", + "enabled": true, + "throttle": null, + "api_key_owner": "elastic", + "created_by": "elastic", + "updated_by": "elastic", + "mute_all": false, + "muted_alert_ids": [], + "updated_at": "2021-02-10T05:37:19.086Z", + "created_at": "2021-02-10T05:37:19.086Z", + "scheduled_task_id": "31563950-b14b-11eb-9a7c-9df284da9f99", + "execution_status": { + "last_execution_date": "2021-02-10T17:55:14.262Z", + "status": "ok" } } -------------------------------------------------- -// TEST - -<1> This queries for rules executed in the last day. Update the values of `lte` and `gte` to query over a different time range. -<2> Use `event.provider: actions` to query for long-running action executions. -<3> Execution durations are stored as nanoseconds. This adds a runtime field to convert that duration into seconds. -<4> This interval buckets the event.duration_in_seconds runtime field into 1 second intervals. Update this value to change the granularity of the buckets. If you are unable to use runtime fields, make sure this aggregation targets `event.duration` and use nanoseconds for the interval. -<5> This retrieves the top 10 rule ids for this duration interval. Update this value to retrieve more rule ids. -This query returns the following: - -[source,json] +The field you’re looking for is the one called `scheduled_task_id` which includes the _id of the Task Manager task, so if you then go to the Console and run the following query, you’ll get the underlying task. +[source, txt] -------------------------------------------------- +GET .kibana_task_manager/_doc/task:31563950-b14b-11eb-9a7c-9df284da9f99 { - "took" : 322, - "timed_out" : false, - "_shards" : { - "total" : 1, - "successful" : 1, - "skipped" : 0, - "failed" : 0 - }, - "hits" : { - "total" : { - "value" : 326, - "relation" : "eq" + "_index" : ".kibana_task_manager_8.0.0_001", + "_id" : "task:31563950-b14b-11eb-9a7c-9df284da9f99", + "_version" : 838, + "_seq_no" : 8791, + "_primary_term" : 1, + "found" : true, + "_source" : { + "migrationVersion" : { + "task" : "7.6.0" }, - "max_score" : null, - "hits" : [ ] - }, - "aggregations" : { - "ruleIdsByExecutionDuration" : { - "buckets" : [ - { - "key" : 0.0, <1> - "doc_count" : 320, - "ruleId" : { - "doc_count" : 320, - "ruleId" : { - "doc_count_error_upper_bound" : 0, - "sum_other_doc_count" : 0, - "buckets" : [ - { - "key" : "1923ada0-a8f3-11eb-a04b-13d723cdfdc5", - "doc_count" : 140 - }, - { - "key" : "15415ecf-cdb0-4fef-950a-f824bd277fe4", - "doc_count" : 130 - }, - { - "key" : "dceeb5d0-6b41-11eb-802b-85b0c1bc8ba2", - "doc_count" : 50 - } - ] - } - } - }, - { - "key" : 30.0, <2> - "doc_count" : 6, - "ruleId" : { - "doc_count" : 6, - "ruleId" : { - "doc_count_error_upper_bound" : 0, - "sum_other_doc_count" : 0, - "buckets" : [ - { - "key" : "41893910-6bca-11eb-9e0d-85d233e3ee35", - "doc_count" : 6 - } - ] - } - } - } - ] - } + "task" : { + "schedule" : { + "interval" : "5s" + }, + "taskType" : "alerting:.index-threshold", + "retryAt" : null, + "runAt" : "2021-05-10T05:18:02.704Z", + "scope" : [ + "alerting" + ], + "startedAt" : null, + "state" : """{"alertInstances":{},"previousStartedAt":"2021-05-10T05:17:45.671Z"}""", + "params" : """{"alertId":"30d856c0-b14b-11eb-9a7c-9df284da9f99","spaceId":"default"}""", + "ownerId" : null, + "scheduledAt" : "2021-05-10T04:50:07.333Z", + "attempts" : 0, + "status" : "idle" + }, + "references" : [ ], + "updated_at" : "2021-05-10T05:17:58.000Z", + "coreMigrationVersion" : "8.0.0", + "type" : "task" } } -------------------------------------------------- -<1> Most rule execution durations fall within the first bucket (0 - 1 seconds). -<2> A single rule with id `41893910-6bca-11eb-9e0d-85d233e3ee35` took between 30 and 31 seconds to execute. -Use the <> to retrieve additional information about rules that take a long time to execute. \ No newline at end of file +What you can see above is the task that backs the rule, and for the rule to work, this task must be in a healthy state. This information is available via <> or via verbose logs if debug logging is enabled. +When diagnosing the health state of the task, you will most likely be interested in the following fields: + +`status`:: This is the current status of the task. Is Task Manager is currently running? Is Task Manager idle, and you’re waiting for it to run? Or has Task Manager has tried to run it and failed? +`runAt`:: This is when the task is scheduled to run next. If this is in the past and the status is idle, Task Manager has fallen behind or isn’t running. If it’s in the past, but the status is running, then Task Manager has picked it up and is working on it, which is considered healthy. +`retryAt`:: Another time field, like runAt. If this field is populated, then Task Manager is currently running the task. If the task doesn’t complete (and isn't marked as failed), then Task Manager will give it another attempt at the time specified under retryAt. + +Investigating the underlying task can help you gauge whether the problem you’re seeing is rooted in the rule not running at all, whether it’s running and failing, or whether it is running, but exhibiting behavior that is different than what was expected (at which point you should focus on the rule itself, rather than the task). + +In addition to the above methods, broadly used the next approaches and common issues: + +* <> +* <> +* <> + +include::troubleshooting/alerting-common-issues.asciidoc[] +include::troubleshooting/event-log-index.asciidoc[] +include::troubleshooting/testing-connectors.asciidoc[] diff --git a/docs/user/alerting/create-and-manage-rules.asciidoc b/docs/user/alerting/create-and-manage-rules.asciidoc index af6714aef662f..cc91ebcd99be2 100644 --- a/docs/user/alerting/create-and-manage-rules.asciidoc +++ b/docs/user/alerting/create-and-manage-rules.asciidoc @@ -152,6 +152,25 @@ You can perform these operations in bulk by multi-selecting rules, and then clic [role="screenshot"] image:images/bulk-mute-disable.png[The Manage rules button lets you mute/unmute, enable/disable, and delete in bulk,width=75%] +[float] +[[importing-and-exporting-rules]] +=== Import and export rules + +To import and export rules, use the <>. + +[NOTE] +============================================== +Some rule types cannot be exported through this interface: + +**Security rules** can be imported and exported using the {security-guide}/rules-ui-management.html#import-export-rules-ui[Security UI]. + +**Stack monitoring rules** are <> for you and therefore cannot be managed via the Saved Objects Management UI. +============================================== + +Rules are disabled on export. You are prompted to re-enable rule on successful import. +[role="screenshot"] +image::images/rules-imported-banner.png[Rules import banner, width=50%] + [float] [[rule-details]] === Drilldown to rule details diff --git a/docs/user/alerting/images/connector-save-and-test.png b/docs/user/alerting/images/connector-save-and-test.png new file mode 100644 index 0000000000000..35e5bcb21a5dc Binary files /dev/null and b/docs/user/alerting/images/connector-save-and-test.png differ diff --git a/docs/user/alerting/images/email-connector-test.png b/docs/user/alerting/images/email-connector-test.png new file mode 100644 index 0000000000000..698b141fda284 Binary files /dev/null and b/docs/user/alerting/images/email-connector-test.png differ diff --git a/docs/user/alerting/images/index-threshold-chart.png b/docs/user/alerting/images/index-threshold-chart.png new file mode 100644 index 0000000000000..f83fa9476eda7 Binary files /dev/null and b/docs/user/alerting/images/index-threshold-chart.png differ diff --git a/docs/user/alerting/images/rules-details-health.png b/docs/user/alerting/images/rules-details-health.png new file mode 100644 index 0000000000000..ffdac4fcd1983 Binary files /dev/null and b/docs/user/alerting/images/rules-details-health.png differ diff --git a/docs/user/alerting/images/rules-management-health.png b/docs/user/alerting/images/rules-management-health.png new file mode 100644 index 0000000000000..e81c4e07dd7b2 Binary files /dev/null and b/docs/user/alerting/images/rules-management-health.png differ diff --git a/docs/user/alerting/images/teams-connector-test.png b/docs/user/alerting/images/teams-connector-test.png new file mode 100644 index 0000000000000..10773a586f367 Binary files /dev/null and b/docs/user/alerting/images/teams-connector-test.png differ diff --git a/docs/user/alerting/troubleshooting/alerting-common-issues.asciidoc b/docs/user/alerting/troubleshooting/alerting-common-issues.asciidoc new file mode 100644 index 0000000000000..c57e9876a4118 --- /dev/null +++ b/docs/user/alerting/troubleshooting/alerting-common-issues.asciidoc @@ -0,0 +1,253 @@ +[role="xpack"] +[[alerting-common-issues]] +=== Common Issues + +This page describes how to resolve common problems you might encounter with Alerting. + +[float] +[[rules-small-check-interval-run-late]] +==== Rules with small check intervals run late + +*Problem* + +Rules with a small check interval, such as every two seconds, run later than scheduled. + +*Solution* + +Rules run as background tasks at a cadence defined by their *check interval*. +When a Rule *check interval* is smaller than the Task Manager <>, the rule will run late. + +Either tweak the <> or increase the *check interval* of the rules in question. + +For more details, see <>. + + +[float] +[[scheduled-rules-run-late]] +==== Rules with the inconsistent cadence + +*Problem* + +Scheduled rules run at an inconsistent cadence, often running late. + +Actions run long after the status of a rule changes, sending a notification of the change too late. + +*Solution* + +Rules and actions run as background tasks by each {kib} instance at a default rate of ten tasks every three seconds. +When diagnosing issues related to Alerting, focus on the tasks that begin with `alerting:` and `actions:`. + +Alerting tasks always begin with `alerting:`. For example, the `alerting:.index-threshold` tasks back the <>. +Action tasks always begin with `actions:`. For example, the `actions:.index` tasks back the <>. + +For more details on monitoring and diagnosing task execution in Task Manager, see <>. + +[float] +[[connector-tls-settings]] +==== Connectors have TLS errors when executing actions + +*Problem* + +When executing actions, a connector gets a TLS socket error when connecting to +the server. + +*Solution* + +Configuration options are available to specialize connections to TLS servers, +including ignoring server certificate validation, and providing certificate +authority data to verify servers using custom certificates. For more details, +see <>. + +[float] +[[rules-long-execution-time]] +==== Rules take a long time to run + +*Problem* + +Rules are taking a long time to execute and are impacting the overall health of your deployment. + +[IMPORTANT] +============================================== +By default, only users with a `superuser` role can query the {kib} event log because it is a system index. To enable additional users to execute this query, assign `read` privileges to the `.kibana-event-log*` index. +============================================== + +*Solution* + +Query for a list of rule ids, bucketed by their execution times: + +[source,console] +-------------------------------------------------- +GET /.kibana-event-log*/_search +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { + "range": { + "@timestamp": { + "gte": "now-1d", <1> + "lte": "now" + } + } + }, + { + "term": { + "event.action": { + "value": "execute" + } + } + }, + { + "term": { + "event.provider": { + "value": "alerting" <2> + } + } + } + ] + } + }, + "runtime_mappings": { <3> + "event.duration_in_seconds": { + "type": "double", + "script": { + "source": "emit(doc['event.duration'].value / 1E9)" + } + } + }, + "aggs": { + "ruleIdsByExecutionDuration": { + "histogram": { + "field": "event.duration_in_seconds", + "min_doc_count": 1, + "interval": 1 <4> + }, + "aggs": { + "ruleId": { + "nested": { + "path": "kibana.saved_objects" + }, + "aggs": { + "ruleId": { + "terms": { + "field": "kibana.saved_objects.id", + "size": 10 <5> + } + } + } + } + } + } + } +} +-------------------------------------------------- +// TEST + +<1> This queries for rules executed in the last day. Update the values of `lte` and `gte` to query over a different time range. +<2> Use `event.provider: actions` to query for long-running action executions. +<3> Execution durations are stored as nanoseconds. This adds a runtime field to convert that duration into seconds. +<4> This interval buckets the `event.duration_in_seconds` runtime field into 1 second intervals. Update this value to change the granularity of the buckets. If you are unable to use runtime fields, make sure this aggregation targets `event.duration` and use nanoseconds for the interval. +<5> This retrieves the top 10 rule ids for this duration interval. Update this value to retrieve more rule ids. + +This query returns the following: + +[source,json] +-------------------------------------------------- +{ + "took" : 322, + "timed_out" : false, + "_shards" : { + "total" : 1, + "successful" : 1, + "skipped" : 0, + "failed" : 0 + }, + "hits" : { + "total" : { + "value" : 326, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ ] + }, + "aggregations" : { + "ruleIdsByExecutionDuration" : { + "buckets" : [ + { + "key" : 0.0, <1> + "doc_count" : 320, + "ruleId" : { + "doc_count" : 320, + "ruleId" : { + "doc_count_error_upper_bound" : 0, + "sum_other_doc_count" : 0, + "buckets" : [ + { + "key" : "1923ada0-a8f3-11eb-a04b-13d723cdfdc5", + "doc_count" : 140 + }, + { + "key" : "15415ecf-cdb0-4fef-950a-f824bd277fe4", + "doc_count" : 130 + }, + { + "key" : "dceeb5d0-6b41-11eb-802b-85b0c1bc8ba2", + "doc_count" : 50 + } + ] + } + } + }, + { + "key" : 30.0, <2> + "doc_count" : 6, + "ruleId" : { + "doc_count" : 6, + "ruleId" : { + "doc_count_error_upper_bound" : 0, + "sum_other_doc_count" : 0, + "buckets" : [ + { + "key" : "41893910-6bca-11eb-9e0d-85d233e3ee35", + "doc_count" : 6 + } + ] + } + } + } + ] + } + } +} +-------------------------------------------------- +<1> Most rule execution durations fall within the first bucket (0 - 1 seconds). +<2> A single rule with id `41893910-6bca-11eb-9e0d-85d233e3ee35` took between 30 and 31 seconds to execute. + +Use the <> to retrieve additional information about rules that take a long time to execute. + +[float] +[[rule-cannot-decrypt-api-key]] +=== Rule cannot decrypt apiKey + +*Problem*: + +The rule fails to execute and has an `Unable to decrypt attribute "apiKey"` error. + +*Solution*: + +This error happens when the `xpack.encryptedSavedObjects.encryptionKey` value used to create the rule does not match the value used during rule execution. Depending on the scenario, there are different ways to solve this problem: + +[cols="2*<"] +|=== + +| If the value in `xpack.encryptedSavedObjects.encryptionKey` was manually changed, and the previous encryption key is still known. +| Ensure any previous encryption key is included in the keys used for <>. + +| If another {kib} instance with a different encryption key connects to the cluster. +| The other {kib} instance might be trying to run the rule using a different encryption key than what the rule was created with. Ensure the encryption keys among all the {kib} instances are the same, and setting <> for previously used encryption keys. + +| If other scenarios don't apply. +| Generate a new API key for the rule by disabling then enabling the rule. + +|=== diff --git a/docs/user/alerting/troubleshooting/event-log-index.asciidoc b/docs/user/alerting/troubleshooting/event-log-index.asciidoc new file mode 100644 index 0000000000000..fa5b5831c04ee --- /dev/null +++ b/docs/user/alerting/troubleshooting/event-log-index.asciidoc @@ -0,0 +1,201 @@ +[role="xpack"] +[[event-log-index]] +=== Event log index + +Use the event log index to determine: + +* Whether a rule successfully ran but its associated actions did not +* Whether a rule was ever activated +* Additional information about rule execution errors +* Duration times for rule and action executions + +[float] +==== Example Event Log Queries + +Event log query to look at all event related to a specific rule id: +[source, txt] +-------------------------------------------------- +GET /.kibana-event-log*/_search +{ + "sort": [ + { + "@timestamp": { + "order": "desc" + } + } + ], + "query": { + "bool": { + "filter": [ + { + "term": { + "event.provider": { + "value": "alerting" + } + } + }, + // optionally filter by specific action event + { + "term": { + "event.action": "active-instance" + | "execute-action" + | "new-instance" + | "recovered-instance" + | "execute" + } + }, + // filter by specific rule id + { + "nested": { + "path": "kibana.saved_objects", + "query": { + "bool": { + "filter": [ + { + "term": { + "kibana.saved_objects.id": { + "value": "b541b690-bfc4-11eb-bf08-05a30cefd1fc" + } + } + }, + { + "term": { + "kibana.saved_objects.type": "alert" + } + } + + ] + } + } + } + } + ] + } + } +} +-------------------------------------------------- + +Event log query to look at all events related to executing a rule or action. These events include duration. +[source, txt] +-------------------------------------------------- +GET /.kibana-event-log*/_search +{ + "sort": [ + { + "@timestamp": { + "order": "desc" + } + } + ], + "query": { + "bool": { + "filter": [ + { + "term": { + "event.action": { + "value": "execute" + } + } + }, + // optionally filter by specific rule or action id + { + "nested": { + "path": "kibana.saved_objects", + "query": { + "bool": { + "filter": [ + { + "term": { + "kibana.saved_objects.id": { + "value": "b541b690-bfc4-11eb-bf08-05a30cefd1fc" + } + } + } + ] + } + } + } + } + ] + } + } +} +-------------------------------------------------- + +Event log query to look at the errors. +You should see an `error.message` property in that event, with a message from the action executor that might provide more detail on why the action encountered an error: +[source, txt] +-------------------------------------------------- +{ + "event": { + "provider": "actions", + "action": "execute", + "start": "2020-03-31T04:27:30.392Z", + "end": "2020-03-31T04:27:30.393Z", + "duration": 1000000 + }, + "kibana": { + "namespace": "default", + "saved_objects": [ + { + "type": "action", + "id": "7a6fd3c6-72b9-44a0-8767-0432b3c70910" + } + ], + }, + "message": "action executed: .server-log:7a6fd3c6-72b9-44a0-8767-0432b3c70910: server-log", + "@timestamp": "2020-03-31T04:27:30.393Z", +} +-------------------------------------------------- + +And see the errors for the rules you might provide the next search query: +[source, txt] +-------------------------------------------------- +{ + "event": { + "provider": "alerting", + "start": "2020-03-31T04:27:30.392Z", + "end": "2020-03-31T04:27:30.393Z", + "duration": 1000000 + }, + "kibana": { + "namespace": "default", + "saved_objects": [ + { + "rel" : "primary", + "type" : "alert", + "id" : "30d856c0-b14b-11eb-9a7c-9df284da9f99" + } + ], + }, + "message": "alert executed: .index-threshold:30d856c0-b14b-11eb-9a7c-9df284da9f99: 'test'", + "error" : { + "message" : "Saved object [action/ef0e2530-b14a-11eb-9a7c-9df284da9f99] not found" + }, +} +-------------------------------------------------- + +You can also query the event log for failures, which should return more specific details about rules which failed by targeting the event.outcome: + +[source, txt] +-------------------------------------------------- +GET .kibana-event-log-*/_search +{ + "query": { + "bool": { + "must": [ + { "match": { "event.outcome": "failure" }} + ] + } + } +} +-------------------------------------------------- + +Here’s an example of what failed credentials from Google SMTP might look like from the response: +[source, txt] +-------------------------------------------------- +"error" : { + "message" : """error sending email: Invalid login: 535-5.7.8 Username and Password not accepted. Learn more at +535 5.7.8 https://support.google.com/mail/?p=BadCredentials e207sm3359731pfh.171 - gsmtp""" +}, +-------------------------------------------------- diff --git a/docs/user/alerting/troubleshooting/testing-connectors.asciidoc b/docs/user/alerting/troubleshooting/testing-connectors.asciidoc new file mode 100644 index 0000000000000..c99ac243f0ad3 --- /dev/null +++ b/docs/user/alerting/troubleshooting/testing-connectors.asciidoc @@ -0,0 +1,72 @@ +[role="xpack"] +[[testing-connectors]] +=== Test connectors + + +By using Kibana Management UI you can test a newly created Connector by navigating to the Test tab of Connector Edit flyout or by clicking "Save & test" button on Create flyout: +[role="screenshot"] +image::user/alerting/images/connector-save-and-test.png[Rule management page with the errors banner] + +or by directly opening the proper connector Edit flyout: +[role="screenshot"] +image::user/alerting/images/email-connector-test.png[Rule management page with the errors banner] + +[role="screenshot"] +image::user/alerting/images/teams-connector-test.png[Five clauses define the condition to detect] + +[float] +==== experimental[] Troubleshooting Connectors with `kbn-action` tool + +Executing an Email action via https://github.com/pmuellr/kbn-action[kbn-action]. In this example, is using a cloud deployment of the stack: + +[source] +-------------------------------------------------- +$ npm -g install pmuellr/kbn-action + +$ export KBN_URLBASE=https://elastic:@.us-east-1.aws.found.io:9243 + +$ kbn-action ls +[ + { + "id": "a692dc89-15b9-4a3c-9e47-9fb6872e49ce", + "actionTypeId": ".email", + "name": "gmail", + "config": { + "from": "test@gmail.com", + "host": "smtp.gmail.com", + "port": 465, + "secure": true, + "service": null + }, + "isPreconfigured": false, + "referencedByCount": 0 + } +] +-------------------------------------------------- +and then execute this: + +[source] +-------------------------------------------------- +$ kbn-action execute a692dc89-15b9-4a3c-9e47-9fb6872e49ce '{subject: "hallo", message: "hallo!", to:["test@yahoo.com"]}' +{ + "status": "ok", + "data": { + "accepted": [ + "test@yahoo.com" + ], + "rejected": [], + "envelopeTime": 100, + "messageTime": 955, + "messageSize": 521, + "response": "250 2.0.0 OK 1593144408 r5sm8625873qtc.20 - gsmtp", + "envelope": { + "from": "test@gmail.com", + "to": [ + "test@yahoo.com" + ] + }, + "messageId": "" + }, + "actionId": "a692dc89-15b9-4a3c-9e47-9fb6872e49ce" +} +-------------------------------------------------- diff --git a/docs/user/api.asciidoc b/docs/user/api.asciidoc index e4faa81c174e9..82f3355759b67 100644 --- a/docs/user/api.asciidoc +++ b/docs/user/api.asciidoc @@ -105,4 +105,5 @@ include::{kib-repo-dir}/api/actions-and-connectors.asciidoc[] include::{kib-repo-dir}/api/dashboard-api.asciidoc[] include::{kib-repo-dir}/api/logstash-configuration-management.asciidoc[] include::{kib-repo-dir}/api/url-shortening.asciidoc[] +include::{kib-repo-dir}/api/task-manager/health.asciidoc[] include::{kib-repo-dir}/api/upgrade-assistant.asciidoc[] diff --git a/docs/user/dashboard/aggregation-reference.asciidoc b/docs/user/dashboard/aggregation-reference.asciidoc index cb5c484def3b9..17bfc19c2e0c9 100644 --- a/docs/user/dashboard/aggregation-reference.asciidoc +++ b/docs/user/dashboard/aggregation-reference.asciidoc @@ -12,91 +12,168 @@ This reference can help simplify the comparison if you need a specific feature. [options="header"] |=== -| Type | Aggregation-based | Lens | TSVB | Timelion | Vega +| Type | Lens | TSVB | Agg-based | Vega | Timelion | Table -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | | -| Table with summary row -^| X -^| X -| +| Bar, line, and area +| ✓ +| ✓ +| ✓ +| ✓ +| ✓ + +| Split chart/small multiples | +| ✓ +| ✓ +| ✓ | -| Bar, line, and area charts -^| X -^| X -^| X -^| X -^| X +| Pie and donut +| ✓ +| +| ✓ +| ✓ +| -| Percentage bar or area chart +| Sunburst +| ✓ | -^| X -^| X +| ✓ +| ✓ | -^| X -| Split bar, line, and area charts -^| X +| Treemap +| ✓ +| | +| ✓ | + +| Heat map +| ✓ +| ✓ +| ✓ +| ✓ | -^| X -| Pie and donut charts -^| X -^| X +| Gauge and Goal | +| ✓ +| ✓ +| ✓ | -^| X -| Sunburst chart -^| X -^| X +| Markdown +| +| ✓ | | | -| Heat map -^| X -^| X +| Metric +| ✓ +| ✓ +| ✓ +| ✓ +| + +| Tag cloud | | -^| X +| ✓ +| ✓ +| -| Gauge and Goal -^| X +|=== + +[float] +[[table-features]] +=== Table features + +[options="header"] +|=== + +| Type | Lens | TSVB | Agg-based + +| Summary row +| ✓ | -^| X +| ✓ + +| Pivot table +| ✓ | | -| Markdown +| Calculated column +| Formula +| ✓ +| Percent only + +| Color by value +| ✓ +| ✓ | + +|=== + +[float] +[[xy-features]] +=== Bar, line, area features + +[options="header"] +|=== + +| Type | Lens | TSVB | Agg-based | Vega | Timelion + +| Dense time series +| Customizable +| ✓ +| Customizable +| ✓ +| ✓ + +| Percentage mode +| ✓ +| ✓ +| ✓ +| ✓ | -^| X + +| Break downs +| 1 +| 1 +| 3 +| ∞ +| 1 + +| Custom color with break downs | +| Only for Filters +| ✓ +| ✓ | -| Metric -^| X -^| X -^| X +| Fit missing values +| ✓ | -^| X +| ✓ +| ✓ +| ✓ -| Tag cloud -^| X +| Synchronized tooltips +| +| ✓ | | | -^| X |=== @@ -111,67 +188,57 @@ For information about {es} bucket aggregations, refer to {ref}/search-aggregatio [options="header"] |=== -| Type | Agg-based | Markdown | Lens | TSVB +| Type | Lens | TSVB | Agg-based | Histogram -^| X -^| X -^| X +| ✓ | +| ✓ | Date histogram -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Date range -^| X -^| X -| +| Use filters | +| ✓ | Filter -^| X -^| X | -^| X +| ✓ +| | Filters -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | GeoHash grid -^| X -^| X | | +| ✓ | IP range -^| X -^| X -| -| +| Use filters +| Use filters +| ✓ | Range -^| X -^| X -^| X -| +| ✓ +| Use filters +| ✓ | Terms -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Significant terms -^| X -^| X | -^| X +| +| ✓ |=== @@ -186,67 +253,57 @@ For information about {es} metrics aggregations, refer to {ref}/search-aggregati [options="header"] |=== -| Type | Agg-based | Markdown | Lens | TSVB +| Type | Lens | TSVB | Agg-based | Metrics with filters +| ✓ | | -^| X -| - -| Average -^| X -^| X -^| X -^| X -| Sum -^| X -^| X -^| X -^| X +| Average, Sum, Max, Min +| ✓ +| ✓ +| ✓ | Unique count (Cardinality) -^| X -^| X -^| X -^| X - -| Max -^| X -^| X -^| X -^| X - -| Min -^| X -^| X -^| X -^| X - -| Percentiles -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ + +| Percentiles and Median +| ✓ +| ✓ +| ✓ | Percentiles Rank -^| X -^| X -| -^| X +| +| ✓ +| ✓ + +| Standard deviation +| +| ✓ +| ✓ + +| Sum of squares +| +| ✓ +| | Top hit (Last value) -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Value count | | +| ✓ + +| Variance +| +| ✓ | -^| X |=== @@ -261,61 +318,94 @@ For information about {es} pipeline aggregations, refer to {ref}/search-aggregat [options="header"] |=== -| Type | Agg-based | Markdown | Lens | TSVB +| Type | Lens | TSVB | Agg-based | Avg bucket -^| X -^| X -| -^| X +| <> +| ✓ +| ✓ | Derivative -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Max bucket -^| X -^| X -| -^| X +| <> +| ✓ +| ✓ | Min bucket -^| X -^| X -| -^| X +| <> +| ✓ +| ✓ | Sum bucket -^| X -^| X -| -^| X +| <> +| ✓ +| ✓ | Moving average -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Cumulative sum -^| X -^| X -^| X -^| X +| ✓ +| ✓ +| ✓ | Bucket script | | +| ✓ + +| Bucket selector +| | -^| X +| | Serial differencing -^| X -^| X | -^| X +| ✓ +| ✓ + +|=== + +[float] +[[custom-functions]] +=== Additional functions + +[options="header"] +|=== + +| Type | Lens | TSVB | Agg-based + +| Counter rate +| ✓ +| ✓ +| + +| <> +| Use <> +| ✓ +| + +| <> +| +| ✓ +| + +| <> +| +| ✓ +| + +| Static value +| +| ✓ +| + |=== @@ -329,41 +419,49 @@ build their advanced visualization. [options="header"] |=== -| Type | Agg-based | Lens | TSVB | Timelion | Vega +| Type | Lens | TSVB | Agg-based | Vega | Timelion -| Math on aggregated data +| Math +| ✓ +| ✓ | -^| X -^| X -^| X -^| X +| ✓ +| ✓ | Visualize two indices +| ✓ +| ✓ | -^| X -^| X -^| X -^| X +| ✓ +| ✓ | Math across indices | | | -^| X -^| X +| ✓ +| ✓ | Time shifts +| ✓ +| ✓ | -^| X -^| X -^| X -^| X +| ✓ +| ✓ | Fully custom {es} queries | | | +| ✓ | -^| X + +| Normalize by time +| ✓ +| ✓ +| +| +| + |=== diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index 0eb4b43466ff9..84c33db31d575 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -112,7 +112,7 @@ The following panel types support drilldowns. ^| X ^| X -| TSVB +| TSVB (only for time series visualizations) ^| X ^| X diff --git a/docs/user/dashboard/images/tsvb_group_by_multiple_fields.png b/docs/user/dashboard/images/tsvb_group_by_multiple_fields.png new file mode 100644 index 0000000000000..3f23189f77254 Binary files /dev/null and b/docs/user/dashboard/images/tsvb_group_by_multiple_fields.png differ diff --git a/docs/user/dashboard/lens-advanced.asciidoc b/docs/user/dashboard/lens-advanced.asciidoc index ec8d90aa4920e..33e0e362058f4 100644 --- a/docs/user/dashboard/lens-advanced.asciidoc +++ b/docs/user/dashboard/lens-advanced.asciidoc @@ -104,7 +104,7 @@ To quickly create many copies of a percentile metric that shows distribution of . From the *Chart Type* dropdown, select *Line*. + [role="screenshot"] -image::images/lens_advanced_2_1.png[Chart type menu with Line selected] +image::images/lens_advanced_2_1.png[Chart type menu with Line selected, width=50%] . From the *Available fields* list, drag and drop *products.price* to the visualization builder. @@ -239,12 +239,11 @@ For each category type that you want to break down, create a filter. Change the legend position to the top of the chart. . From the *Legend* dropdown, select the top position. - + [role="screenshot"] image::images/lens_advanced_4_1.png[Prices share by category] - Click *Save and return*. +. Click *Save and return*. [discrete] [[view-the-cumulative-number-of-products-sold-on-weekends]] @@ -299,7 +298,8 @@ image::images/lens_advanced_5_2.png[Line chart with cumulative sum of orders mad [[compare-time-ranges]] === Compare time ranges -*Lens* allows you to compare the currently selected time range with historical data using the *Time shift* option. +*Lens* allows you to compare the currently selected time range with historical data using the *Time shift* option. To calculate the percent +change, use *Formula*. Time shifts can be used on any metric. The special shift *previous* will show the time window preceding the currently selected one, spanning the same duration. For example, if *Last 7 days* is selected in the time filter, *previous* will show data from 14 days ago to 7 days ago. @@ -326,9 +326,32 @@ To compare current sales numbers with sales from a week ago, follow these steps: .. Click *Time shift* .. Click the *1 week* option. You can also define custom shifts by typing amount followed by time unit (like *1w* for a one week shift), then hit enter. - ++ [role="screenshot"] -image::images/lens_time_shift.png[Line chart with week-over-week sales comparison] +image::images/lens_time_shift.png[Line chart with week-over-week sales comparison, width=50%] + +. Click *Save and return*. + +[float] +[[compare-time-as-percent]] +==== Compare time ranges as a percent change + +To view the percent change in sales between the current time and the previous week, use a *Formula*: + +. Open *Lens*. + +. From the *Available fields* list, drag and drop *Records* to the visualization builder. + +. Click *Count of Records*, then click *Formula*. + +. Type `count() / count(shift='1w') - 1`. To learn more about the formula +syntax, click *Help*. + +. Click *Value format* and select *Percent* with 0 decimals. + +. In the *Display name* field, enter `Percent change`, then click *Close*. + +. Click *Save and return*. [discrete] [[view-customers-over-time-by-continents]] @@ -366,18 +389,14 @@ To split the customers count by continent: . From the *Available fields* list, drag and drop *geoip.continent_name* to the *Columns* field of the editor. + [role="screenshot"] -image::images/lens_advanced_6_1.png[Table with daily customers by continent configuration] +image::images/lens_advanced_6_1.png[Table with daily customers by continent configuration, width=50%] . Click *Save and return*. + [discrete] === Save the dashboard -By default the dashboard attempts to match the palette across panels, but in this case there's no need for that, so it can be disabled. - -[role="screenshot"] -image::images/lens_advanced_7_1.png[Disable palette sync in dashboard] - Now that you have a complete overview of your ecommerce sales data, save the dashboard. . In the toolbar, click *Save*. diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index 4ecfcc9250122..2071f17ecff3d 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -139,6 +139,42 @@ image::images/lens_drag_drop_3.gif[Using drag and drop to reorder] . Press Space bar to confirm, or to cancel, press Esc. +[float] +[[lens-formulas]] +==== Use formulas to perform math + +Formulas let you perform math on aggregated data in Lens by typing +math and quick functions. To access formulas, +click the *Formula* tab in the dimension editor. Access the complete +reference for formulas from the help menu. + +The most common formulas are dividing two values to produce a percent. +To display accurately, set *Value format* to *Percent*. + +Filter ratio:: + +Use `kql=''` to filter one set of documents and compare it to other documents within the same grouping. +For example, to see how the error rate changes over time: ++ +``` +count(kql='response.status_code > 400') / count() +``` + +Week over week:: Use `shift='1w'` to get the value of each grouping from +the previous week. Time shift should not be used with the *Top values* function. ++ +``` +percentile(system.network.in.bytes, percentile=99) / +percentile(system.network.in.bytes, percentile=99, shift='1w') +``` + +Percent of total:: Formulas can calculate `overall_sum` for all the groupings, +which lets you convert each grouping into a percent of total: ++ +``` +sum(products.base_price) / overall_sum(sum(products.base_price)) +``` + [float] [[lens-faq]] ==== Frequently asked questions diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index b69df7c7d26d6..93ee3627bd8a0 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -166,3 +166,15 @@ For other types of month over month calculations, use <> o Calculating the duration between the start and end of an event is unsupported in *TSVB* because *TSVB* requires correlation between different time periods. *TSVB* requires that the duration is pre-calculated. + +[float] +===== How do I group on multiple fields? + +To group with multiple fields, create runtime fields in the index pattern you are visualizing. + +. Create a runtime field. Refer to <> for more information. ++ +[role="screenshot"] +image::images/tsvb_group_by_multiple_fields.png[Group by multiple fields] + +. Create a new TSVB visualization and group by this field. \ No newline at end of file diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 6829e129cd3b6..638a21dbe1cc7 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -23,16 +23,8 @@ Learn more about {kib} extension, additional *Vega* resources, and examples. ====== Automatic sizing Most users will want their Vega visualizations to take the full available space, so unlike -Vega examples, `width` and `height` are not required parameters in {kib}. To set the width -or height manually, set `autosize: none`. For example, to set the height to a specific pixel value: - -``` -autosize: none -width: container -height: 200 -``` - -The default {kib} settings which are inherited by your visualizations are: +Vega examples, `width` and `height` are not required parameters in {kib} because your +spec will be merged with the default {kib} settings in most cases: ``` autosize: { @@ -43,17 +35,36 @@ width: container height: container ``` -{kib} is able to merge your custom `autosize` settings with the defaults. The options `fit-x` -and `fit-y` are supported but not recommended over the default `fit` setting. +These default settings are *not* applied if: + +* <> +* Your spec is Vega-Lite and contains a facet, row, column, repeat, or concat operator. In these +cases, providing `width` and `height` will affect the child size. + +To set the width or height manually, set `autosize: none` and provide the exact pixel sizes, including +padding for the title, legend and axes. + +``` +autosize: none +width: 600 +height: 200 +padding: { + top: 20 + bottom: 20 + left: 55 + right: 150 +} +``` To learn more, read about -https://vega.github.io/vega/docs/specification/#autosize[autosize] -in the Vega documentation. +https://vega.github.io/vega/docs/specification/#autosize[Vega autosize] +and https://vega.github.io/vega-lite/docs/size.html[Vega-Lite autosize]. -WARNING: Autosize in Vega-Lite has https://vega.github.io/vega-lite/docs/size.html#limitations[several limitations] -that can result in a warning like `Autosize "fit" only works for single views and layered views.` -The recommended fix for this warning is to convert your spec to Vega using the <> +NOTE: Autosize in Vega-Lite has https://vega.github.io/vega-lite/docs/size.html#limitations[several limitations] +which can affect the height and width of your visualization, but these limitations do not exist in Vega. +If you need full control, convert your spec to Vega using the <> `VEGA_DEBUG.vega_spec` output. +To disable these warnings, you can <>. [float] [[vega-theme]] diff --git a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc index 4b63313b2b96e..363562d4cd193 100644 --- a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc +++ b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc @@ -955,3 +955,73 @@ Tasks are not running, and the server logs contain the following error message: Inline scripts are a hard requirement for Task Manager to function. To enable inline scripting, see the Elasticsearch documentation for {ref}/modules-scripting-security.html#allowed-script-types-setting[configuring allowed script types setting]. + +[float] +[[task-runat-is-in-the-past]] +==== What do I do if the Task’s `runAt` is in the past? + +*Problem*: + +Tasks' property `runAt` is in the past. + +*Solution*: + +Wait a bit before declaring it as a lost cause, as Task Manager might just be falling behind on its work. +You should take a look at the Kibana log and see what you can find that relates to Task Manager. +In a healthy environment you should see a log line that indicates that Task Manager was successfully started when Kibana was: +[source, txt] +-------------------------------------------------- +server log [12:41:33.672] [info][plugins][taskManager][taskManager] TaskManager is identified by the Kibana UUID: 5b2de169-2785-441b-ae8c-186a1936b17d +-------------------------------------------------- + +If you see that message and no other errors that relate to Task Manager, it’s most likely that Task Manager is running fine and has simply not had the chance to pick the task up yet. +If, on the other hand, the runAt is severely overdue, then it’s worth looking for other Task Manager or Alerting related errors, as something else may have gone wrong. +It’s worth looking at the status field, as it might have failed, which would explain why it hasn’t been picked up or it might be running which means the task might simply be a very long running one. + +[float] +[[task-marked-failed]] +==== What do I do if the Task is marked as failed? + +*Problem*: + +Tasks marked as failed. + +*Solution*: + +Broadly speaking the Alerting framework is meant to gracefully handle the cases where a task is failing by rescheduling a fresh run in the future. If this fails to happen, then that means something has gone wrong in the underlying implementation and this isn’t expected. +Ideally you should try and find any log lines that relate to this rule and its task, and use these to help us investigate further. + +[float] +[[task-manager-kibana-log]] +==== Task Manager Kibana Log +Task manager will write log lines to the Kibana Log on certain occasions. Below are some common log lines and what they mean. + +Task Manager has run out of Available Workers: +[source, txt] +-------------------------------------------------- +server log [12:41:33.672] [info][plugins][taskManager][taskManager] [Task Ownership]: Task Manager has skipped Claiming Ownership of available tasks at it has ran out Available Workers. +-------------------------------------------------- + +This log message tells us that Task Manager is not managing to keep up with the sheer amount of work it has been tasked with completing. This might mean that Rules are not running at the frequency that was expected (instead of running every 5 minutes, it runs every 7-8 minutes, just as an example). + +By default Task Manager is limited to 10 tasks and this can be bumped up by setting a higher number in the kibana.yml file using the `xpack.task_manager.max_workers` configuration. It is important to keep in mind that a higher number of tasks running at any given time means more load on both Kibana and Elasticsearch, so only change this setting if increasing load in your environment makes sense. + +Another approach to addressing this might be to tell workers to run at a higher rate, rather than adding more of them, which would be configured using xpack.task_manager.poll_interval. This value dictates how often Task Manager checks to see if there’s more work to be done and uses milliseconds (by default it is 3000, which means an interval of 3 seconds). + +Before changing either of these numbers it’s highly recommended to investigate what Task Manager can’t keep up - Are there an unusually high number of rules in the system? Are rules failing often, forcing Task Manager to re-run them constantly? Is Kibana under heavy load? There could be a variety of issues, none of which should be solved by simply changing these configurations. + +Task TaskType failed in attempt to run: +[source, txt] +-------------------------------------------------- +server log [12:41:33.672] [info][plugins][taskManager][taskManager] Task TaskType "alerting:example.always-firing" failed in attempt to run: Unable to load resource ‘/api/something’ +-------------------------------------------------- + +This log message tells us that when Task Manager was running one of our rules, it’s task errored and, as a result, failed. In this case we can tell that the rule that failed was of type alerting:example.always-firing and that the reason it failed was Unable to load resource ‘/api/something’ . This is a contrived example, but broadly, if you see a message with this kind of format, then this tells you a lot about where the problem might be. + +For example, in this case, we’d expect to see a corresponding log line from the Alerting framework itself, saying that the rule failed. You should look in the Kibana log for a line similar to the log line below (probably shortly before the Task Manager log line): + +Executing Alert "27559295-44e4-4983-aa1b-94fe043ab4f9" has resulted in Error: Unable to load resource ‘/api/something’ + +This would confirm that the error did in fact happen in the rule itself (rather than the Task Manager) and it would help us pin-point the specific ID of the rule which failed: 27559295-44e4-4983-aa1b-94fe043ab4f9 + +We can now use the ID to find out more about that rule by using the http endpoint to find that rule’s configuration and current state to help investigate what might have caused the issue. diff --git a/examples/locator_examples/README.md b/examples/locator_examples/README.md new file mode 100644 index 0000000000000..bcc1b19f689d1 --- /dev/null +++ b/examples/locator_examples/README.md @@ -0,0 +1,8 @@ +# Locator examples + +This example plugin shows how to: + + - Register a URL locator. + - Return locator from plugin contract. + +To run this example, use the command `yarn start --run-examples`. Navigate to the locator app. diff --git a/examples/url_generators_examples/kibana.json b/examples/locator_examples/kibana.json similarity index 74% rename from examples/url_generators_examples/kibana.json rename to examples/locator_examples/kibana.json index 9658f5c7300aa..df336b2ab3613 100644 --- a/examples/url_generators_examples/kibana.json +++ b/examples/locator_examples/kibana.json @@ -1,5 +1,5 @@ { - "id": "urlGeneratorsExamples", + "id": "locatorExamples", "version": "0.0.1", "kibanaVersion": "kibana", "server": false, @@ -7,6 +7,6 @@ "requiredPlugins": ["share"], "optionalPlugins": [], "extraPublicDirs": [ - "public/url_generator" + "public/locator" ] } diff --git a/examples/url_generators_examples/public/app.tsx b/examples/locator_examples/public/app.tsx similarity index 100% rename from examples/url_generators_examples/public/app.tsx rename to examples/locator_examples/public/app.tsx diff --git a/examples/locator_examples/public/index.ts b/examples/locator_examples/public/index.ts new file mode 100644 index 0000000000000..50da3501805fa --- /dev/null +++ b/examples/locator_examples/public/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LocatorExamplesPlugin } from './plugin'; + +export { + HelloLocator, + HelloLocatorV1Params, + HelloLocatorV2Params, + HelloLocatorParams, + HELLO_LOCATOR, +} from './locator'; + +export const plugin = () => new LocatorExamplesPlugin(); diff --git a/examples/locator_examples/public/locator.ts b/examples/locator_examples/public/locator.ts new file mode 100644 index 0000000000000..18caeca08564e --- /dev/null +++ b/examples/locator_examples/public/locator.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SerializableState, MigrateFunction } from 'src/plugins/kibana_utils/common'; +import { LocatorDefinition, LocatorPublic } from '../../../src/plugins/share/public'; + +export const HELLO_LOCATOR = 'HELLO_LOCATOR'; + +export interface HelloLocatorV1Params extends SerializableState { + name: string; +} + +export interface HelloLocatorV2Params extends SerializableState { + firstName: string; + lastName: string; +} + +export type HelloLocatorParams = HelloLocatorV2Params; + +const migrateV1ToV2: MigrateFunction = ( + v1: HelloLocatorV1Params +) => { + const v2: HelloLocatorV2Params = { + firstName: v1.name, + lastName: '', + }; + + return v2; +}; + +export type HelloLocator = LocatorPublic; + +export class HelloLocatorDefinition implements LocatorDefinition { + public readonly id = HELLO_LOCATOR; + + public readonly getLocation = async ({ firstName, lastName }: HelloLocatorParams) => { + return { + app: 'locatorExamples', + path: `/hello?firstName=${encodeURIComponent(firstName)}&lastName=${encodeURIComponent( + lastName + )}`, + state: {}, + }; + }; + + public readonly migrations = { + '0.0.2': (migrateV1ToV2 as unknown) as MigrateFunction, + }; +} diff --git a/examples/url_generators_examples/public/plugin.tsx b/examples/locator_examples/public/plugin.tsx similarity index 53% rename from examples/url_generators_examples/public/plugin.tsx rename to examples/locator_examples/public/plugin.tsx index f797c92d4c902..4364c46e6138c 100644 --- a/examples/url_generators_examples/public/plugin.tsx +++ b/examples/locator_examples/public/plugin.tsx @@ -8,44 +8,27 @@ import { SharePluginStart, SharePluginSetup } from '../../../src/plugins/share/public'; import { Plugin, CoreSetup, AppMountParameters, AppNavLinkStatus } from '../../../src/core/public'; -import { - HelloLinkGeneratorState, - createHelloPageLinkGenerator, - LegacyHelloLinkGeneratorState, - HELLO_URL_GENERATOR_V1, - HELLO_URL_GENERATOR, - helloPageLinkGeneratorV1, -} from './url_generator'; +import { HelloLocator, HelloLocatorDefinition } from './locator'; -declare module '../../../src/plugins/share/public' { - export interface UrlGeneratorStateMapping { - [HELLO_URL_GENERATOR_V1]: LegacyHelloLinkGeneratorState; - [HELLO_URL_GENERATOR]: HelloLinkGeneratorState; - } +interface SetupDeps { + share: SharePluginSetup; } interface StartDeps { share: SharePluginStart; } -interface SetupDeps { - share: SharePluginSetup; +export interface LocatorExamplesSetup { + locator: HelloLocator; } -const APP_ID = 'urlGeneratorsExamples'; - -export class AccessLinksExamplesPlugin implements Plugin { - public setup(core: CoreSetup, { share: { urlGenerators } }: SetupDeps) { - urlGenerators.registerUrlGenerator( - createHelloPageLinkGenerator(async () => ({ - appBasePath: (await core.getStartServices())[0].application.getUrlForApp(APP_ID), - })) - ); - - urlGenerators.registerUrlGenerator(helloPageLinkGeneratorV1); +export class LocatorExamplesPlugin + implements Plugin { + public setup(core: CoreSetup, plugins: SetupDeps) { + const locator = plugins.share.url.locators.create(new HelloLocatorDefinition()); core.application.register({ - id: APP_ID, + id: 'locatorExamples', title: 'Access links examples', navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { @@ -58,6 +41,10 @@ export class AccessLinksExamplesPlugin implements Plugin { +const ActionsExplorer = ({ share }: Props) => { const [migratedLinks, setMigratedLinks] = useState([] as MigratedLink[]); const [buildingLinks, setBuildingLinks] = useState(false); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); + /** * Lets pretend we grabbed these links from a persistent store, like a saved object. - * Some of these links were created with older versions of the hello link generator. - * They use deprecated generator ids. + * Some of these links were created with older versions of the hello locator. */ - const [persistedLinks, setPersistedLinks] = useState([ + const [persistedLinks, setPersistedLinks] = useState< + Array<{ + id: string; + version: string; + linkText: string; + params: HelloLocatorV1Params | HelloLocatorV2Params; + }> + >([ { - id: HELLO_URL_GENERATOR_V1, + id: HELLO_LOCATOR, + version: '0.0.1', linkText: 'Say hello to Mary', - state: { + params: { name: 'Mary', }, }, { - id: HELLO_URL_GENERATOR, + id: HELLO_LOCATOR, + version: '0.0.2', linkText: 'Say hello to George', - state: { + params: { firstName: 'George', lastName: 'Washington', }, @@ -71,30 +79,38 @@ const ActionsExplorer = ({ getLinkGenerator }: Props) => { const updateLinks = async () => { const updatedLinks = await Promise.all( persistedLinks.map(async (savedLink) => { - const generator = getLinkGenerator(savedLink.id); - const link = await generator.createUrl(savedLink.state); + const locator = share.url.locators.get(savedLink.id); + if (!locator) return; + let params: HelloLocatorV1Params | HelloLocatorV2Params = savedLink.params; + if (savedLink.version === '0.0.1') { + const migration = locator.migrations['0.0.2']; + if (migration) { + params = migration(params) as HelloLocatorV2Params; + } + } + const link = await locator.getUrl(params, { absolute: false }); return { - isDeprecated: generator.isDeprecated, linkText: savedLink.linkText, link, - }; + version: savedLink.version, + } as MigratedLink; }) ); - setMigratedLinks(updatedLinks); + setMigratedLinks(updatedLinks as MigratedLink[]); setBuildingLinks(false); }; updateLinks(); - }, [getLinkGenerator, persistedLinks]); + }, [share, persistedLinks]); return ( - Access links explorer + Locator explorer -

Create new links using the most recent version of a url generator.

+

Create new links using the most recent version of a locator.

{ setPersistedLinks([ ...persistedLinks, { - id: HELLO_URL_GENERATOR, - state: { firstName, lastName }, + id: HELLO_LOCATOR, + version: '0.0.2', + params: { firstName, lastName }, linkText: `Say hello to ${firstName} ${lastName}`, }, ]) @@ -122,10 +139,10 @@ const ActionsExplorer = ({ getLinkGenerator }: Props) => {

Existing links retrieved from storage. The links that were generated from legacy - generators are in red. This can be useful for developers to know they will have to + locators are in red. This can be useful for developers to know they will have to migrate persisted state or in a future version of Kibana, these links may no longer - work. They still work now because legacy url generators must provide a state - migration function. + work. They still work now because legacy locators must provide state migration + functions.

{buildingLinks ? ( @@ -134,7 +151,7 @@ const ActionsExplorer = ({ getLinkGenerator }: Props) => { migratedLinks.map((link) => ( new AccessLinksExamplesPlugin(); +export const plugin = () => new LocatorExplorerPlugin(); diff --git a/examples/url_generators_explorer/public/page.tsx b/examples/locator_explorer/public/page.tsx similarity index 100% rename from examples/url_generators_explorer/public/page.tsx rename to examples/locator_explorer/public/page.tsx diff --git a/examples/url_generators_explorer/public/plugin.tsx b/examples/locator_explorer/public/plugin.tsx similarity index 63% rename from examples/url_generators_explorer/public/plugin.tsx rename to examples/locator_explorer/public/plugin.tsx index f5f12df669d6c..3e8382f2a606f 100644 --- a/examples/url_generators_explorer/public/plugin.tsx +++ b/examples/locator_explorer/public/plugin.tsx @@ -6,30 +6,30 @@ * Side Public License, v 1. */ -import { SharePluginStart } from '../../../src/plugins/share/public'; +import { SharePluginSetup, SharePluginStart } from '../../../src/plugins/share/public'; import { Plugin, CoreSetup, AppMountParameters, AppNavLinkStatus } from '../../../src/core/public'; import { DeveloperExamplesSetup } from '../../developer_examples/public'; -interface StartDeps { - share: SharePluginStart; -} - interface SetupDeps { developerExamples: DeveloperExamplesSetup; + share: SharePluginSetup; +} + +interface StartDeps { + share: SharePluginStart; } -export class AccessLinksExplorerPlugin implements Plugin { - public setup(core: CoreSetup, { developerExamples }: SetupDeps) { +export class LocatorExplorerPlugin implements Plugin { + public setup(core: CoreSetup, { developerExamples, share }: SetupDeps) { core.application.register({ - id: 'urlGeneratorsExplorer', - title: 'Access links explorer', + id: 'locatorExplorer', + title: 'Locator explorer', navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { - const depsStart = (await core.getStartServices())[1]; const { renderApp } = await import('./app'); return renderApp( { - getLinkGenerator: depsStart.share.urlGenerators.getUrlGenerator, + share, }, params ); @@ -37,18 +37,18 @@ export class AccessLinksExplorerPlugin implements Plugin; - -export const createHelloPageLinkGenerator = ( - getStartServices: () => Promise<{ appBasePath: string }> -): UrlGeneratorsDefinition => ({ - id: HELLO_URL_GENERATOR, - createUrl: async (state) => { - const startServices = await getStartServices(); - const appBasePath = startServices.appBasePath; - const parsedUrl = url.parse(window.location.href); - - return url.format({ - protocol: parsedUrl.protocol, - host: parsedUrl.host, - pathname: `${appBasePath}/hello`, - query: { - ...state, - }, - }); - }, -}); - -/** - * The name of this legacy generator id changes, but the *value* stays the same. - */ -export const HELLO_URL_GENERATOR_V1 = 'HELLO_URL_GENERATOR'; - -export interface HelloLinkStateV1 { - name: string; -} - -export type LegacyHelloLinkGeneratorState = UrlGeneratorState< - HelloLinkStateV1, - typeof HELLO_URL_GENERATOR, - HelloLinkState ->; - -export const helloPageLinkGeneratorV1: UrlGeneratorsDefinition = { - id: HELLO_URL_GENERATOR_V1, - isDeprecated: true, - migrate: async (state) => { - return { id: HELLO_URL_GENERATOR, state: { firstName: state.name, lastName: '' } }; - }, -}; diff --git a/jest.config.integration.js b/jest.config.integration.js index 50767932a52d7..b6ecb4569b643 100644 --- a/jest.config.integration.js +++ b/jest.config.integration.js @@ -13,7 +13,6 @@ module.exports = { rootDir: '.', roots: ['/src', '/packages'], testMatch: ['**/integration_tests**/*.test.{js,mjs,ts,tsx}'], - testRunner: 'jasmine2', testPathIgnorePatterns: preset.testPathIgnorePatterns.filter( (pattern) => !pattern.includes('integration_tests') ), diff --git a/package.json b/package.json index 29371c9532915..2e22a4e0ccf77 100644 --- a/package.json +++ b/package.json @@ -102,17 +102,17 @@ "@elastic/charts": "30.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.13", - "@elastic/ems-client": "7.13.0", - "@elastic/eui": "33.0.0", + "@elastic/ems-client": "7.14.0", + "@elastic/eui": "34.5.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", - "@elastic/react-search-ui": "^1.5.1", + "@elastic/react-search-ui": "^1.6.0", "@elastic/request-crypto": "1.1.4", "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set", - "@elastic/search-ui-app-search-connector": "^1.5.0", + "@elastic/search-ui-app-search-connector": "^1.6.0", "@elastic/ui-ace": "0.2.3", "@hapi/accept": "^5.0.2", "@hapi/boom": "^9.1.1", @@ -128,36 +128,37 @@ "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics", "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader", "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils", + "@kbn/common-utils": "link:bazel-bin/packages/kbn-common-utils", "@kbn/config": "link:bazel-bin/packages/kbn-config", "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema", "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto", - "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", - "@kbn/interpreter": "link:packages/kbn-interpreter", + "@kbn/interpreter": "link:bazel-bin/packages/kbn-interpreter", "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", "@kbn/logging": "link:bazel-bin/packages/kbn-logging", + "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", "@kbn/rule-data-utils": "link:bazel-bin/packages/kbn-rule-data-utils", - "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", "@kbn/securitysolution-hook-utils": "link:bazel-bin/packages/kbn-securitysolution-hook-utils", - "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types", "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types", + "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils", "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api", + "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks", "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils", + "@kbn/securitysolution-t-grid": "link:bazel-bin/packages/kbn-securitysolution-t-grid", "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils", "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository", "@kbn/std": "link:bazel-bin/packages/kbn-std", "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", - "@kbn/ui-framework": "link:packages/kbn-ui-framework", + "@kbn/ui-framework": "link:bazel-bin/packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:bazel-bin/packages/kbn-ui-shared-deps", "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", - "@kbn/common-utils": "link:bazel-bin/packages/kbn-common-utils", "@kbn/utils": "link:bazel-bin/packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", @@ -217,6 +218,8 @@ "cytoscape-dagre": "^2.2.2", "d3": "3.5.17", "d3-array": "1.2.4", + "d3-cloud": "1.2.5", + "d3-interpolate": "^3.0.1", "d3-scale": "1.0.7", "d3-shape": "^1.1.0", "d3-time": "^1.1.0", @@ -270,6 +273,7 @@ "jquery": "^3.5.0", "js-levenshtein": "^1.1.6", "js-search": "^1.4.3", + "js-sha256": "^0.9.0", "js-yaml": "^3.14.0", "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", @@ -446,8 +450,6 @@ "@bazel/typescript": "^3.5.1", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", - "@elastic/apm-rum": "^5.6.1", - "@elastic/apm-rum-react": "^1.2.5", "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", @@ -456,7 +458,7 @@ "@jest/reporters": "^26.6.2", "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser", "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset", - "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", + "@kbn/cli-dev-mode": "link:bazel-bin/packages/kbn-cli-dev-mode", "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils", "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils", "@kbn/es": "link:bazel-bin/packages/kbn-es", @@ -464,10 +466,11 @@ "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana", "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", "@kbn/expect": "link:bazel-bin/packages/kbn-expect", - "@kbn/optimizer": "link:packages/kbn-optimizer", + "@kbn/optimizer": "link:bazel-bin/packages/kbn-optimizer", "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", - "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", + "@kbn/plugin-helpers": "link:bazel-bin/packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", + "@kbn/spec-to-console": "link:bazel-bin/packages/kbn-spec-to-console", "@kbn/storybook": "link:bazel-bin/packages/kbn-storybook", "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", @@ -490,6 +493,7 @@ "@storybook/core-events": "^6.1.20", "@storybook/node-logger": "^6.1.20", "@storybook/react": "^6.1.20", + "@storybook/testing-react": "^0.0.17", "@storybook/theming": "^6.1.20", "@testing-library/dom": "^7.30.3", "@testing-library/jest-dom": "^5.11.10", @@ -513,6 +517,7 @@ "@types/cytoscape": "^3.14.0", "@types/d3": "^3.5.43", "@types/d3-array": "^1.2.7", + "@types/d3-interpolate": "^2.0.0", "@types/d3-scale": "^2.1.1", "@types/d3-shape": "^1.3.1", "@types/d3-time": "^1.0.10", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 61034c562b447..225a41a5fd8b6 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -3,7 +3,7 @@ filegroup( name = "build", srcs = [ - "//packages/elastic-datemath:build", + "//packages/elastic-datemath:build", "//packages/elastic-eslint-config-kibana:build", "//packages/elastic-safer-lodash-set:build", "//packages/kbn-ace:build", @@ -12,6 +12,7 @@ filegroup( "//packages/kbn-apm-utils:build", "//packages/kbn-babel-code-parser:build", "//packages/kbn-babel-preset:build", + "//packages/kbn-cli-dev-mode:build", "//packages/kbn-common-utils:build", "//packages/kbn-config:build", "//packages/kbn-config-schema:build", @@ -23,12 +24,15 @@ filegroup( "//packages/kbn-eslint-plugin-eslint:build", "//packages/kbn-expect:build", "//packages/kbn-i18n:build", + "//packages/kbn-interpreter:build", "//packages/kbn-io-ts-utils:build", "//packages/kbn-legacy-logging:build", "//packages/kbn-logging:build", "//packages/kbn-mapbox-gl:build", "//packages/kbn-monaco:build", + "//packages/kbn-optimizer:build", "//packages/kbn-plugin-generator:build", + "//packages/kbn-plugin-helpers:build", "//packages/kbn-rule-data-utils:build", "//packages/kbn-securitysolution-list-constants:build", "//packages/kbn-securitysolution-io-ts-types:build", @@ -40,13 +44,16 @@ filegroup( "//packages/kbn-securitysolution-list-utils:build", "//packages/kbn-securitysolution-utils:build", "//packages/kbn-securitysolution-es-utils:build", + "//packages/kbn-securitysolution-t-grid:build", "//packages/kbn-securitysolution-hook-utils:build", "//packages/kbn-server-http-tools:build", "//packages/kbn-server-route-repository:build", + "//packages/kbn-spec-to-console:build", "//packages/kbn-std:build", "//packages/kbn-storybook:build", "//packages/kbn-telemetry-tools:build", "//packages/kbn-tinymath:build", + "//packages/kbn-ui-framework:build", "//packages/kbn-ui-shared-deps:build", "//packages/kbn-utility-types:build", "//packages/kbn-utils:build", diff --git a/packages/elastic-eslint-config-kibana/.eslintrc.js b/packages/elastic-eslint-config-kibana/.eslintrc.js index a8c2e9546510e..3220a01184004 100644 --- a/packages/elastic-eslint-config-kibana/.eslintrc.js +++ b/packages/elastic-eslint-config-kibana/.eslintrc.js @@ -75,6 +75,11 @@ module.exports = { to: '@kbn/test', disallowedMessage: `import from the root of @kbn/test instead` }, + { + from: 'react-intl', + to: '@kbn/i18n/react', + disallowedMessage: `import from @kbn/i18n/react instead` + } ], ], }, diff --git a/packages/kbn-cli-dev-mode/BUILD.bazel b/packages/kbn-cli-dev-mode/BUILD.bazel new file mode 100644 index 0000000000000..ab1b6601f429b --- /dev/null +++ b/packages/kbn-cli-dev-mode/BUILD.bazel @@ -0,0 +1,103 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-cli-dev-mode" +PKG_REQUIRE_NAME = "@kbn/cli-dev-mode" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = ["**/*.test.*"], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md" +] + +SRC_DEPS = [ + "//packages/kbn-config", + "//packages/kbn-config-schema", + "//packages/kbn-dev-utils", + "//packages/kbn-logging", + "//packages/kbn-optimizer", + "//packages/kbn-server-http-tools", + "//packages/kbn-std", + "//packages/kbn-utils", + "@npm//@hapi/h2o2", + "@npm//@hapi/hapi", + "@npm//argsplit", + "@npm//chokidar", + "@npm//elastic-apm-node", + "@npm//execa", + "@npm//getopts", + "@npm//lodash", + "@npm//moment", + "@npm//rxjs", + "@npm//supertest", +] + +TYPES_DEPS = [ + "@npm//@types/hapi__h2o2", + "@npm//@types/hapi__hapi", + "@npm//@types/getopts", + "@npm//@types/jest", + "@npm//@types/lodash", + "@npm//@types/node", + "@npm//@types/supertest", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index dd491de55c075..ac86ee2ef369b 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -5,15 +5,7 @@ "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", "private": true, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - }, "kibana": { "devOnly": true - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-cli-dev-mode/tsconfig.json b/packages/kbn-cli-dev-mode/tsconfig.json index 4436d27dbff88..0c71ad8e245d4 100644 --- a/packages/kbn-cli-dev-mode/tsconfig.json +++ b/packages/kbn-cli-dev-mode/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-cli-dev-mode/src", "types": [ diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index ad9ecb059031c..52cfd0b71b8ba 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -133,19 +133,20 @@ exports.Cluster = class Cluster { } /** - * Unpakcs a tar or zip file containing the data directory for an + * Unpacks a tar or zip file containing the data directory for an * ES cluster. * * @param {String} installPath * @param {String} archivePath + * @param {String} [extractDirName] */ - async extractDataDirectory(installPath, archivePath) { + async extractDataDirectory(installPath, archivePath, extractDirName = 'data') { this._log.info(chalk.bold(`Extracting data directory`)); this._log.indent(4); // decompress excludes the root directory as that is how our archives are // structured. This works in our favor as we can explicitly extract into the data dir - const extractPath = path.resolve(installPath, 'data'); + const extractPath = path.resolve(installPath, extractDirName); this._log.info(`Data archive: ${archivePath}`); this._log.info(`Extract path: ${extractPath}`); @@ -237,9 +238,12 @@ exports.Cluster = class Cluster { * @param {Object} options * @property {string|Array} options.esArgs * @property {string} options.esJavaOpts + * @property {Boolean} options.skipNativeRealmSetup * @return {undefined} */ - _exec(installPath, options = {}) { + _exec(installPath, opts = {}) { + const { skipNativeRealmSetup = false, ...options } = opts; + if (this._process || this._outcome) { throw new Error('ES has already been started'); } @@ -303,6 +307,10 @@ exports.Cluster = class Cluster { // once the http port is available setup the native realm this._nativeRealmSetup = httpPort.then(async (port) => { + if (skipNativeRealmSetup) { + return; + } + const caCert = await this._caCertPromise; const nativeRealm = new NativeRealm({ port, diff --git a/packages/kbn-eslint-plugin-eslint/rules/module_migration.js b/packages/kbn-eslint-plugin-eslint/rules/module_migration.js index 7e81680bc2b6e..87a1bae8eac1a 100644 --- a/packages/kbn-eslint-plugin-eslint/rules/module_migration.js +++ b/packages/kbn-eslint-plugin-eslint/rules/module_migration.js @@ -9,7 +9,7 @@ const path = require('path'); const KIBANA_ROOT = path.resolve(__dirname, '../../..'); -function checkModuleNameNode(context, mappings, node) { +function checkModuleNameNode(context, mappings, node, desc = 'Imported') { const mapping = mappings.find( (mapping) => mapping.from === node.value || node.value.startsWith(`${mapping.from}/`) ); @@ -42,7 +42,7 @@ function checkModuleNameNode(context, mappings, node) { } context.report({ - message: `Imported module "${node.value}" should be "${newSource}"`, + message: `${desc} module "${node.value}" should be "${newSource}"`, loc: node.loc, fix(fixer) { return fixer.replaceText(node, `'${newSource}'`); @@ -101,6 +101,11 @@ module.exports = { ImportDeclaration(node) { checkModuleNameNode(context, mappings, node.source); }, + ExportNamedDeclaration(node) { + if (node.source) { + checkModuleNameNode(context, mappings, node.source, 'Re-exported'); + } + }, CallExpression(node) { if ( node.callee.type === 'Identifier' && diff --git a/packages/kbn-eslint-plugin-eslint/rules/module_migration.test.js b/packages/kbn-eslint-plugin-eslint/rules/module_migration.test.js new file mode 100644 index 0000000000000..d89c8df213994 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/module_migration.test.js @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const { RuleTester } = require('eslint'); +const rule = require('./module_migration'); +const dedent = require('dedent'); + +const ruleTester = new RuleTester({ + parser: require.resolve('babel-eslint'), + parserOptions: { + ecmaVersion: 2018, + }, +}); + +ruleTester.run('@kbn/eslint/module-migration', rule, { + valid: [ + { + code: dedent` + import "bar" + require('bar') + export { foo } from "bar" + export const foo2 = 'bar' + `, + + options: [ + [ + { + from: 'foo', + to: 'bar', + }, + ], + ], + }, + ], + + invalid: [ + { + code: dedent` + import "foo" + require('foo/foo2') + export { foo } from 'foo' + export const foo2 = 'bar' + `, + + options: [ + [ + { + from: 'foo', + to: 'bar', + }, + ], + ], + errors: [ + { + line: 1, + message: 'Imported module "foo" should be "bar"', + }, + { + line: 2, + message: 'Imported module "foo/foo2" should be "bar/foo2"', + }, + { + line: 3, + message: 'Re-exported module "foo" should be "bar"', + }, + ], + }, + ], +}); diff --git a/packages/kbn-i18n/src/react/index.tsx b/packages/kbn-i18n/src/react/index.tsx index 08fa7173978d9..a6d8ed17d3b66 100644 --- a/packages/kbn-i18n/src/react/index.tsx +++ b/packages/kbn-i18n/src/react/index.tsx @@ -6,7 +6,9 @@ * Side Public License, v 1. */ +// eslint-disable-next-line @kbn/eslint/module_migration import { InjectedIntl as _InjectedIntl, InjectedIntlProps as _InjectedIntlProps } from 'react-intl'; +// eslint-disable-next-line @kbn/eslint/module_migration export type { InjectedIntl, InjectedIntlProps } from 'react-intl'; export { @@ -19,7 +21,7 @@ export { FormattedMessage, FormattedHTMLMessage, // Only used for testing. Use I18nProvider otherwise. - IntlProvider as __IntlProvider, + IntlProvider as __IntlProvider, // eslint-disable-next-line @kbn/eslint/module_migration } from 'react-intl'; export { I18nProvider } from './provider'; diff --git a/packages/kbn-i18n/src/react/inject.tsx b/packages/kbn-i18n/src/react/inject.tsx index a76d5e5110748..dec12bc5dd03b 100644 --- a/packages/kbn-i18n/src/react/inject.tsx +++ b/packages/kbn-i18n/src/react/inject.tsx @@ -12,4 +12,5 @@ * More docs and examples can be found here https://github.com/yahoo/react-intl/wiki/API#injection-api */ +// eslint-disable-next-line @kbn/eslint/module_migration export { injectIntl as injectI18n } from 'react-intl'; diff --git a/packages/kbn-i18n/src/react/provider.tsx b/packages/kbn-i18n/src/react/provider.tsx index 2d88125291aa0..fc0f6769c7160 100644 --- a/packages/kbn-i18n/src/react/provider.tsx +++ b/packages/kbn-i18n/src/react/provider.tsx @@ -8,6 +8,8 @@ import * as PropTypes from 'prop-types'; import * as React from 'react'; + +// eslint-disable-next-line @kbn/eslint/module_migration import { IntlProvider } from 'react-intl'; import * as i18n from '../core'; diff --git a/packages/kbn-interpreter/.babelrc b/packages/kbn-interpreter/.babelrc deleted file mode 100644 index 309b3d5b3233d..0000000000000 --- a/packages/kbn-interpreter/.babelrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "presets": ["@kbn/babel-preset/webpack_preset"], - "plugins": [ - "@babel/plugin-transform-modules-commonjs", - ["@babel/plugin-transform-runtime", { - "regenerator": true - }] - ] -} diff --git a/packages/kbn-interpreter/.npmignore b/packages/kbn-interpreter/.npmignore deleted file mode 100644 index b9bc539e63ce4..0000000000000 --- a/packages/kbn-interpreter/.npmignore +++ /dev/null @@ -1,3 +0,0 @@ -src -tasks -.babelrc diff --git a/packages/kbn-interpreter/BUILD.bazel b/packages/kbn-interpreter/BUILD.bazel new file mode 100644 index 0000000000000..c29faf65638ca --- /dev/null +++ b/packages/kbn-interpreter/BUILD.bazel @@ -0,0 +1,99 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@npm//peggy:index.bzl", "peggy") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-interpreter" +PKG_REQUIRE_NAME = "@kbn/interpreter" + +SOURCE_FILES = glob( + [ + "src/**/*", + ] +) + +TYPE_FILES = [] + +SRCS = SOURCE_FILES + TYPE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "common/package.json", + "package.json", +] + +SRC_DEPS = [ + "@npm//lodash", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/lodash", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +peggy( + name = "grammar", + data = [ + ":grammar/grammar.peggy" + ], + output_dir = True, + args = [ + "--allowed-start-rules", + "expression,argument", + "-o", + "$(@D)/index.js", + "./%s/grammar/grammar.peggy" % package_name() + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + allow_js = True, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [":grammar"], + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-interpreter/common/package.json b/packages/kbn-interpreter/common/package.json index 62061138234d9..2f5277a8e8652 100644 --- a/packages/kbn-interpreter/common/package.json +++ b/packages/kbn-interpreter/common/package.json @@ -1,6 +1,5 @@ { "private": true, "main": "../target/common/index.js", - "types": "../target/common/index.d.ts", - "jsnext:main": "../src/common/index.js" + "types": "../target/common/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-interpreter/src/common/lib/grammar.peg b/packages/kbn-interpreter/grammar/grammar.peggy similarity index 100% rename from packages/kbn-interpreter/src/common/lib/grammar.peg rename to packages/kbn-interpreter/grammar/grammar.peggy diff --git a/packages/kbn-interpreter/package.json b/packages/kbn-interpreter/package.json index fc0936f4b5f53..efdb30e105186 100644 --- a/packages/kbn-interpreter/package.json +++ b/packages/kbn-interpreter/package.json @@ -2,11 +2,5 @@ "name": "@kbn/interpreter", "private": "true", "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.0", - "scripts": { - "interpreter:peg": "../../node_modules/.bin/pegjs src/common/lib/grammar.peg", - "build": "node scripts/build", - "kbn:bootstrap": "node scripts/build --dev", - "kbn:watch": "node scripts/build --dev --watch" - } + "license": "SSPL-1.0 OR Elastic License 2.0" } \ No newline at end of file diff --git a/packages/kbn-interpreter/src/common/index.js b/packages/kbn-interpreter/src/common/index.ts similarity index 76% rename from packages/kbn-interpreter/src/common/index.js rename to packages/kbn-interpreter/src/common/index.ts index b83d8180980cd..524c854b40429 100644 --- a/packages/kbn-interpreter/src/common/index.js +++ b/packages/kbn-interpreter/src/common/index.ts @@ -6,11 +6,19 @@ * Side Public License, v 1. */ -export { fromExpression, toExpression, safeElementFromExpression } from './lib/ast'; +export { + fromExpression, + toExpression, + safeElementFromExpression, + Ast, + ExpressionFunctionAST, +} from './lib/ast'; export { Fn } from './lib/fn'; export { getType } from './lib/get_type'; export { castProvider } from './lib/cast'; -export { parse } from './lib/grammar'; +// @ts-expect-error +// @internal +export { parse } from '../../grammar'; export { getByAlias } from './lib/get_by_alias'; export { Registry } from './lib/registry'; export { addRegistries, register, registryFactory } from './registries'; diff --git a/packages/kbn-interpreter/src/common/lib/ast.d.ts b/packages/kbn-interpreter/src/common/lib/ast.d.ts deleted file mode 100644 index 0e95cb9901df0..0000000000000 --- a/packages/kbn-interpreter/src/common/lib/ast.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export type ExpressionArgAST = string | boolean | number | Ast; - -export interface ExpressionFunctionAST { - type: 'function'; - function: string; - arguments: { - [key: string]: ExpressionArgAST[]; - }; -} - -export interface Ast { - type: 'expression'; - chain: ExpressionFunctionAST[]; -} - -export declare function fromExpression(expression: string): Ast; -export declare function toExpression(astObj: Ast, type?: string): string; diff --git a/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js b/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js index c67a266e1276a..a098a3fdce0f6 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js +++ b/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { fromExpression } from './ast'; +import { fromExpression } from '@kbn/interpreter/target/common/lib/ast'; import { getType } from './get_type'; describe('ast fromExpression', () => { diff --git a/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js b/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js index c60412f05c15a..b500ca06836a4 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js +++ b/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { toExpression } from './ast'; +import { toExpression } from '@kbn/interpreter/common'; describe('ast toExpression', () => { describe('single expression', () => { diff --git a/packages/kbn-interpreter/src/common/lib/ast.js b/packages/kbn-interpreter/src/common/lib/ast.ts similarity index 75% rename from packages/kbn-interpreter/src/common/lib/ast.js rename to packages/kbn-interpreter/src/common/lib/ast.ts index fb471e34ccc69..791c94809f35c 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.js +++ b/packages/kbn-interpreter/src/common/lib/ast.ts @@ -7,12 +7,35 @@ */ import { getType } from './get_type'; -import { parse } from './grammar'; +// @ts-expect-error +import { parse } from '../../../grammar'; -function getArgumentString(arg, argKey, level = 0) { +export type ExpressionArgAST = string | boolean | number | Ast; + +export interface ExpressionFunctionAST { + type: 'function'; + function: string; + arguments: { + [key: string]: ExpressionArgAST[]; + }; +} + +export interface Ast { + /** @internal */ + function: any; + /** @internal */ + arguments: any; + type: 'expression'; + chain: ExpressionFunctionAST[]; + /** @internal */ + replace(regExp: RegExp, s: string): string; +} + +function getArgumentString(arg: Ast, argKey: string | undefined, level = 0) { const type = getType(arg); - function maybeArgKey(argKey, argString) { + // eslint-disable-next-line @typescript-eslint/no-shadow + function maybeArgKey(argKey: string | null | undefined, argString: string) { return argKey == null || argKey === '_' ? argString : `${argKey}=${argString}`; } @@ -36,7 +59,7 @@ function getArgumentString(arg, argKey, level = 0) { throw new Error(`Invalid argument type in AST: ${type}`); } -function getExpressionArgs(block, level = 0) { +function getExpressionArgs(block: Ast, level = 0) { const args = block.arguments; const hasValidArgs = typeof args === 'object' && args != null && !Array.isArray(args); @@ -45,7 +68,7 @@ function getExpressionArgs(block, level = 0) { const argKeys = Object.keys(args); const MAX_LINE_LENGTH = 80; // length before wrapping arguments return argKeys.map((argKey) => - args[argKey].reduce((acc, arg) => { + args[argKey].reduce((acc: any, arg: any) => { const argString = getArgumentString(arg, argKey, level); const lineLength = acc.split('\n').pop().length; @@ -63,12 +86,12 @@ function getExpressionArgs(block, level = 0) { ); } -function fnWithArgs(fnName, args) { +function fnWithArgs(fnName: any, args: any[]) { if (!args || args.length === 0) return fnName; return `${fnName} ${args.join(' ')}`; } -function getExpression(chain, level = 0) { +function getExpression(chain: any[], level = 0) { if (!chain) throw new Error('Expressions must contain a chain'); // break new functions onto new lines if we're not in a nested/sub-expression @@ -90,7 +113,7 @@ function getExpression(chain, level = 0) { .join(separator); } -export function fromExpression(expression, type = 'expression') { +export function fromExpression(expression: string, type = 'expression'): Ast { try { return parse(String(expression), { startRule: type }); } catch (e) { @@ -99,7 +122,7 @@ export function fromExpression(expression, type = 'expression') { } // TODO: OMG This is so bad, we need to talk about the right way to handle bad expressions since some are element based and others not -export function safeElementFromExpression(expression) { +export function safeElementFromExpression(expression: string) { try { return fromExpression(expression); } catch (e) { @@ -116,8 +139,11 @@ Thanks for understanding, } // TODO: Respect the user's existing formatting -export function toExpression(astObj, type = 'expression') { - if (type === 'argument') return getArgumentString(astObj); +export function toExpression(astObj: Ast, type = 'expression'): string { + if (type === 'argument') { + // @ts-ignore + return getArgumentString(astObj); + } const validType = ['expression', 'function'].includes(getType(astObj)); if (!validType) throw new Error('Expression must be an expression or argument function'); diff --git a/packages/kbn-interpreter/src/common/lib/get_type.js b/packages/kbn-interpreter/src/common/lib/get_type.ts similarity index 92% rename from packages/kbn-interpreter/src/common/lib/get_type.js rename to packages/kbn-interpreter/src/common/lib/get_type.ts index 7ae6dab029176..b6dff67bf5dc9 100644 --- a/packages/kbn-interpreter/src/common/lib/get_type.js +++ b/packages/kbn-interpreter/src/common/lib/get_type.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export function getType(node) { +export function getType(node: any): string { if (node == null) return 'null'; if (typeof node === 'object') { if (!node.type) throw new Error('Objects must have a type property'); diff --git a/packages/kbn-interpreter/src/common/lib/grammar.js b/packages/kbn-interpreter/src/common/lib/grammar.js deleted file mode 100644 index 3f473b1beea63..0000000000000 --- a/packages/kbn-interpreter/src/common/lib/grammar.js +++ /dev/null @@ -1,1053 +0,0 @@ -/* - * Generated by PEG.js 0.10.0. - * - * http://pegjs.org/ - */ - -"use strict"; - -function peg$subclass(child, parent) { - function ctor() { this.constructor = child; } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); -} - -function peg$SyntaxError(message, expected, found, location) { - this.message = message; - this.expected = expected; - this.found = found; - this.location = location; - this.name = "SyntaxError"; - - if (typeof Error.captureStackTrace === "function") { - Error.captureStackTrace(this, peg$SyntaxError); - } -} - -peg$subclass(peg$SyntaxError, Error); - -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - "class": function(expectation) { - var escapedParts = "", - i; - - for (i = 0; i < expectation.parts.length; i++) { - escapedParts += expectation.parts[i] instanceof Array - ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1]) - : classEscape(expectation.parts[i]); - } - - return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]"; - }, - - any: function(expectation) { - return "any character"; - }, - - end: function(expectation) { - return "end of input"; - }, - - other: function(expectation) { - return expectation.description; - } - }; - - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } - - function literalEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function classEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/\]/g, '\\]') - .replace(/\^/g, '\\^') - .replace(/-/g, '\\-') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } - - function describeExpected(expected) { - var descriptions = new Array(expected.length), - i, j; - - for (i = 0; i < expected.length; i++) { - descriptions[i] = describeExpectation(expected[i]); - } - - descriptions.sort(); - - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } - - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; - } - } - - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; - } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; - -function peg$parse(input, options) { - options = options !== void 0 ? options : {}; - - var peg$FAILED = {}, - - peg$startRuleFunctions = { expression: peg$parseexpression, argument: peg$parseargument }, - peg$startRuleFunction = peg$parseexpression, - - peg$c0 = "|", - peg$c1 = peg$literalExpectation("|", false), - peg$c2 = function(first, fn) { return fn; }, - peg$c3 = function(first, rest) { - return addMeta({ - type: 'expression', - chain: first ? [first].concat(rest) : [] - }, text(), location()); - }, - peg$c4 = peg$otherExpectation("function"), - peg$c5 = function(name, arg_list) { - return addMeta({ - type: 'function', - function: name, - arguments: arg_list - }, text(), location()); - }, - peg$c6 = "=", - peg$c7 = peg$literalExpectation("=", false), - peg$c8 = function(name, value) { - return { name, value }; - }, - peg$c9 = function(value) { - return { name: '_', value }; - }, - peg$c10 = "$", - peg$c11 = peg$literalExpectation("$", false), - peg$c12 = "{", - peg$c13 = peg$literalExpectation("{", false), - peg$c14 = "}", - peg$c15 = peg$literalExpectation("}", false), - peg$c16 = function(expression) { return expression; }, - peg$c17 = function(value) { - return addMeta(value, text(), location()); - }, - peg$c18 = function(arg) { return arg; }, - peg$c19 = function(args) { - return args.reduce((accumulator, { name, value }) => ({ - ...accumulator, - [name]: (accumulator[name] || []).concat(value) - }), {}); - }, - peg$c20 = /^[a-zA-Z0-9_\-]/, - peg$c21 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "_", "-"], false, false), - peg$c22 = function(name) { - return name.join(''); - }, - peg$c23 = peg$otherExpectation("literal"), - peg$c24 = "\"", - peg$c25 = peg$literalExpectation("\"", false), - peg$c26 = function(chars) { return chars.join(''); }, - peg$c27 = "'", - peg$c28 = peg$literalExpectation("'", false), - peg$c29 = function(string) { // this also matches nulls, booleans, and numbers - var result = string.join(''); - // Sort of hacky, but PEG doesn't have backtracking so - // a null/boolean/number rule is hard to read, and performs worse - if (result === 'null') return null; - if (result === 'true') return true; - if (result === 'false') return false; - if (isNaN(Number(result))) return result; // 5bears - return Number(result); - }, - peg$c30 = /^[ \t\r\n]/, - peg$c31 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false), - peg$c32 = "\\", - peg$c33 = peg$literalExpectation("\\", false), - peg$c34 = /^["'(){}<>[\]$`|= \t\n\r]/, - peg$c35 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], false, false), - peg$c36 = function(sequence) { return sequence; }, - peg$c37 = /^[^"'(){}<>[\]$`|= \t\n\r]/, - peg$c38 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], true, false), - peg$c39 = /^[^"]/, - peg$c40 = peg$classExpectation(["\""], true, false), - peg$c41 = /^[^']/, - peg$c42 = peg$classExpectation(["'"], true, false), - - peg$currPos = 0, - peg$savedPos = 0, - peg$posDetailsCache = [{ line: 1, column: 1 }], - peg$maxFailPos = 0, - peg$maxFailExpected = [], - peg$silentFails = 0, - - peg$result; - - if ("startRule" in options) { - if (!(options.startRule in peg$startRuleFunctions)) { - throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); - } - - peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; - } - - function text() { - return input.substring(peg$savedPos, peg$currPos); - } - - function location() { - return peg$computeLocation(peg$savedPos, peg$currPos); - } - - function expected(description, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildStructuredError( - [peg$otherExpectation(description)], - input.substring(peg$savedPos, peg$currPos), - location - ); - } - - function error(message, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildSimpleError(message, location); - } - - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; - } - - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; - } - - function peg$anyExpectation() { - return { type: "any" }; - } - - function peg$endExpectation() { - return { type: "end" }; - } - - function peg$otherExpectation(description) { - return { type: "other", description: description }; - } - - function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos], p; - - if (details) { - return details; - } else { - p = pos - 1; - while (!peg$posDetailsCache[p]) { - p--; - } - - details = peg$posDetailsCache[p]; - details = { - line: details.line, - column: details.column - }; - - while (p < pos) { - if (input.charCodeAt(p) === 10) { - details.line++; - details.column = 1; - } else { - details.column++; - } - - p++; - } - - peg$posDetailsCache[pos] = details; - return details; - } - } - - function peg$computeLocation(startPos, endPos) { - var startPosDetails = peg$computePosDetails(startPos), - endPosDetails = peg$computePosDetails(endPos); - - return { - start: { - offset: startPos, - line: startPosDetails.line, - column: startPosDetails.column - }, - end: { - offset: endPos, - line: endPosDetails.line, - column: endPosDetails.column - } - }; - } - - function peg$fail(expected) { - if (peg$currPos < peg$maxFailPos) { return; } - - if (peg$currPos > peg$maxFailPos) { - peg$maxFailPos = peg$currPos; - peg$maxFailExpected = []; - } - - peg$maxFailExpected.push(expected); - } - - function peg$buildSimpleError(message, location) { - return new peg$SyntaxError(message, null, null, location); - } - - function peg$buildStructuredError(expected, found, location) { - return new peg$SyntaxError( - peg$SyntaxError.buildMessage(expected, found), - expected, - found, - location - ); - } - - function peg$parseexpression() { - var s0, s1, s2, s3, s4, s5, s6, s7; - - s0 = peg$currPos; - s1 = peg$parsespace(); - if (s1 === peg$FAILED) { - s1 = null; - } - if (s1 !== peg$FAILED) { - s2 = peg$parsefunction(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 124) { - s5 = peg$c0; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c1); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parsespace(); - if (s6 === peg$FAILED) { - s6 = null; - } - if (s6 !== peg$FAILED) { - s7 = peg$parsefunction(); - if (s7 !== peg$FAILED) { - peg$savedPos = s4; - s5 = peg$c2(s2, s7); - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 124) { - s5 = peg$c0; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c1); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parsespace(); - if (s6 === peg$FAILED) { - s6 = null; - } - if (s6 !== peg$FAILED) { - s7 = peg$parsefunction(); - if (s7 !== peg$FAILED) { - peg$savedPos = s4; - s5 = peg$c2(s2, s7); - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c3(s2, s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsefunction() { - var s0, s1, s2; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parseidentifier(); - if (s1 !== peg$FAILED) { - s2 = peg$parsearg_list(); - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c5(s1, s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c4); } - } - - return s0; - } - - function peg$parseargument_assignment() { - var s0, s1, s2, s3, s4, s5; - - s0 = peg$currPos; - s1 = peg$parseidentifier(); - if (s1 !== peg$FAILED) { - s2 = peg$parsespace(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 61) { - s3 = peg$c6; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c7); } - } - if (s3 !== peg$FAILED) { - s4 = peg$parsespace(); - if (s4 === peg$FAILED) { - s4 = null; - } - if (s4 !== peg$FAILED) { - s5 = peg$parseargument(); - if (s5 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c8(s1, s5); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseargument(); - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c9(s1); - } - s0 = s1; - } - - return s0; - } - - function peg$parseargument() { - var s0, s1, s2, s3, s4; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 36) { - s1 = peg$c10; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c11); } - } - if (s1 === peg$FAILED) { - s1 = null; - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 123) { - s2 = peg$c12; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c13); } - } - if (s2 !== peg$FAILED) { - s3 = peg$parseexpression(); - if (s3 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 125) { - s4 = peg$c14; - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c15); } - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c16(s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseliteral(); - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c17(s1); - } - s0 = s1; - } - - return s0; - } - - function peg$parsearg_list() { - var s0, s1, s2, s3, s4; - - s0 = peg$currPos; - s1 = []; - s2 = peg$currPos; - s3 = peg$parsespace(); - if (s3 !== peg$FAILED) { - s4 = peg$parseargument_assignment(); - if (s4 !== peg$FAILED) { - peg$savedPos = s2; - s3 = peg$c18(s4); - s2 = s3; - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$currPos; - s3 = peg$parsespace(); - if (s3 !== peg$FAILED) { - s4 = peg$parseargument_assignment(); - if (s4 !== peg$FAILED) { - peg$savedPos = s2; - s3 = peg$c18(s4); - s2 = s3; - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } - if (s1 !== peg$FAILED) { - s2 = peg$parsespace(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c19(s1); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseidentifier() { - var s0, s1, s2; - - s0 = peg$currPos; - s1 = []; - if (peg$c20.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - if (peg$c20.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c22(s1); - } - s0 = s1; - - return s0; - } - - function peg$parseliteral() { - var s0, s1; - - peg$silentFails++; - s0 = peg$parsephrase(); - if (s0 === peg$FAILED) { - s0 = peg$parseunquoted_string_or_number(); - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c23); } - } - - return s0; - } - - function peg$parsephrase() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 34) { - s1 = peg$c24; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parsedq_char(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parsedq_char(); - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 34) { - s3 = peg$c24; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c26(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 39) { - s1 = peg$c27; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parsesq_char(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parsesq_char(); - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 39) { - s3 = peg$c27; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c26(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseunquoted_string_or_number() { - var s0, s1, s2; - - s0 = peg$currPos; - s1 = []; - s2 = peg$parseunquoted(); - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$parseunquoted(); - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c29(s1); - } - s0 = s1; - - return s0; - } - - function peg$parsespace() { - var s0, s1; - - s0 = []; - if (peg$c30.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c31); } - } - if (s1 !== peg$FAILED) { - while (s1 !== peg$FAILED) { - s0.push(s1); - if (peg$c30.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c31); } - } - } - } else { - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseunquoted() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (peg$c34.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c35); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c37.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c38); } - } - } - - return s0; - } - - function peg$parsedq_char() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c24; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c39.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c40); } - } - } - - return s0; - } - - function peg$parsesq_char() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 39) { - s2 = peg$c27; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c41.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c42); } - } - } - - return s0; - } - - - function addMeta(node, text, { start: { offset: start }, end: { offset: end } }) { - if (!options.addMeta) return node; - return { node, text, start, end }; - } - - - peg$result = peg$startRuleFunction(); - - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } -} - -module.exports = { - SyntaxError: peg$SyntaxError, - parse: peg$parse -}; diff --git a/packages/kbn-interpreter/src/common/lib/registry.d.ts b/packages/kbn-interpreter/src/common/lib/registry.d.ts deleted file mode 100644 index 766839ebf0e02..0000000000000 --- a/packages/kbn-interpreter/src/common/lib/registry.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export class Registry { - constructor(prop?: string); - - public wrapper(obj: ItemSpec): Item; - - public register(fn: () => ItemSpec): void; - - public toJS(): { [key: string]: any }; - - public toArray(): Item[]; - - public get(name: string): Item; - - public getProp(): string; - - public reset(): void; -} diff --git a/packages/kbn-interpreter/src/common/lib/registry.js b/packages/kbn-interpreter/src/common/lib/registry.ts similarity index 73% rename from packages/kbn-interpreter/src/common/lib/registry.js rename to packages/kbn-interpreter/src/common/lib/registry.ts index 309f92ea24f6d..11f41ff736e96 100644 --- a/packages/kbn-interpreter/src/common/lib/registry.js +++ b/packages/kbn-interpreter/src/common/lib/registry.ts @@ -8,49 +8,59 @@ import { clone } from 'lodash'; -export class Registry { +export class Registry { + private readonly _prop: string; + // eslint-disable-next-line @typescript-eslint/ban-types + private _indexed: Object; + constructor(prop = 'name') { if (typeof prop !== 'string') throw new Error('Registry property name must be a string'); this._prop = prop; this._indexed = new Object(); } - wrapper(obj) { + wrapper(obj: ItemSpec): Item { + // @ts-ignore return obj; } - register(fn) { + register(fn: () => ItemSpec): void { const obj = typeof fn === 'function' ? fn() : fn; + // @ts-ignore if (typeof obj !== 'object' || !obj[this._prop]) { throw new Error(`Registered functions must return an object with a ${this._prop} property`); } + // @ts-ignore this._indexed[obj[this._prop].toLowerCase()] = this.wrapper(obj); } - toJS() { + toJS(): { [key: string]: any } { return Object.keys(this._indexed).reduce((acc, key) => { + // @ts-ignore acc[key] = this.get(key); return acc; }, {}); } - toArray() { + toArray(): Item[] { return Object.keys(this._indexed).map((key) => this.get(key)); } - get(name) { + get(name: string): Item { + // @ts-ignore if (name === undefined) return null; const lowerCaseName = name.toLowerCase(); + // @ts-ignore return this._indexed[lowerCaseName] ? clone(this._indexed[lowerCaseName]) : null; } - getProp() { + getProp(): string { return this._prop; } - reset() { + reset(): void { this._indexed = new Object(); } } diff --git a/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js b/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js deleted file mode 100644 index f831545743f10..0000000000000 --- a/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-disable */ -import util from 'util'; -console.log(util.format('hello world')); diff --git a/packages/kbn-interpreter/tasks/build/cli.js b/packages/kbn-interpreter/tasks/build/cli.js deleted file mode 100644 index 82e4475b409c3..0000000000000 --- a/packages/kbn-interpreter/tasks/build/cli.js +++ /dev/null @@ -1,82 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -const { relative } = require('path'); - -const getopts = require('getopts'); -const del = require('del'); -const supportsColor = require('supports-color'); -const { ToolingLog, withProcRunner, pickLevelFromFlags } = require('@kbn/dev-utils'); - -const { ROOT_DIR, BUILD_DIR } = require('./paths'); - -const unknownFlags = []; -const flags = getopts(process.argv, { - boolean: ['watch', 'dev', 'help', 'debug'], - unknown(name) { - unknownFlags.push(name); - }, -}); - -const log = new ToolingLog({ - level: pickLevelFromFlags(flags), - writeTo: process.stdout, -}); - -if (unknownFlags.length) { - log.error(`Unknown flag(s): ${unknownFlags.join(', ')}`); - flags.help = true; - process.exitCode = 1; -} - -if (flags.help) { - log.info(` - Simple build tool for @kbn/interpreter package - - --dev Build for development, include source maps - --watch Run in watch mode - --debug Turn on debug logging - `); - process.exit(); -} - -withProcRunner(log, async (proc) => { - log.info('Deleting old output'); - await del(BUILD_DIR); - - const cwd = ROOT_DIR; - const env = { ...process.env }; - if (supportsColor.stdout) { - env.FORCE_COLOR = 'true'; - } - - log.info(`Starting babel ${flags.watch ? ' in watch mode' : ''}`); - await Promise.all([ - proc.run('babel ', { - cmd: 'babel', - args: [ - 'src', - '--ignore', - `*.test.js`, - '--out-dir', - relative(cwd, BUILD_DIR), - '--copy-files', - ...(flags.dev ? ['--source-maps', 'inline'] : []), - ...(flags.watch ? ['--watch'] : ['--quiet']), - ], - wait: true, - env, - cwd, - }), - ]); - - log.success('Complete'); -}).catch((error) => { - log.error(error); - process.exit(1); -}); diff --git a/packages/kbn-interpreter/tasks/build/paths.js b/packages/kbn-interpreter/tasks/build/paths.js deleted file mode 100644 index a4cdba90a110a..0000000000000 --- a/packages/kbn-interpreter/tasks/build/paths.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -const { resolve } = require('path'); - -exports.ROOT_DIR = resolve(__dirname, '../../'); -exports.SOURCE_DIR = resolve(exports.ROOT_DIR, 'src'); -exports.BUILD_DIR = resolve(exports.ROOT_DIR, 'target'); - -exports.BABEL_PRESET_PATH = require.resolve('@kbn/babel-preset/webpack_preset'); diff --git a/packages/kbn-interpreter/tsconfig.json b/packages/kbn-interpreter/tsconfig.json index 3b81bbb118a55..011ed877146e8 100644 --- a/packages/kbn-interpreter/tsconfig.json +++ b/packages/kbn-interpreter/tsconfig.json @@ -1,7 +1,21 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/kbn-interpreter" + "allowJs": true, + "incremental": true, + "outDir": "./target", + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-interpreter/src", + "stripInternal": true, + "types": [ + "jest", + "node" + ] }, - "include": ["index.d.ts", "src/**/*.d.ts"] + "include": [ + "src/**/*", + ] } diff --git a/packages/kbn-io-ts-utils/BUILD.bazel b/packages/kbn-io-ts-utils/BUILD.bazel index 6b26173fe8f36..053030a6f11a9 100644 --- a/packages/kbn-io-ts-utils/BUILD.bazel +++ b/packages/kbn-io-ts-utils/BUILD.bazel @@ -25,6 +25,7 @@ NPM_MODULE_EXTRA_FILES = [ ] SRC_DEPS = [ + "//packages/kbn-config-schema", "@npm//fp-ts", "@npm//io-ts", "@npm//lodash", diff --git a/packages/kbn-io-ts-utils/src/index.ts b/packages/kbn-io-ts-utils/src/index.ts index 418a5a41a2bec..a60bc2086fa3a 100644 --- a/packages/kbn-io-ts-utils/src/index.ts +++ b/packages/kbn-io-ts-utils/src/index.ts @@ -12,3 +12,4 @@ export { strictKeysRt } from './strict_keys_rt'; export { isoToEpochRt } from './iso_to_epoch_rt'; export { toNumberRt } from './to_number_rt'; export { toBooleanRt } from './to_boolean_rt'; +export { toJsonSchema } from './to_json_schema'; diff --git a/packages/kbn-io-ts-utils/src/props_to_schema/index.ts b/packages/kbn-io-ts-utils/src/props_to_schema/index.ts new file mode 100644 index 0000000000000..5915df1b0102e --- /dev/null +++ b/packages/kbn-io-ts-utils/src/props_to_schema/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { schema, Type } from '@kbn/config-schema'; +import { isLeft } from 'fp-ts/lib/Either'; + +export function propsToSchema>(type: T): Type> { + return schema.object( + {}, + { + unknowns: 'allow', + validate: (val) => { + const decoded = type.decode(val); + + if (isLeft(decoded)) { + return PathReporter.report(decoded).join('\n'); + } + }, + } + ); +} diff --git a/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts index ab20ca42a283e..6b19026cb1be5 100644 --- a/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts @@ -10,9 +10,76 @@ import * as t from 'io-ts'; import { isRight, isLeft } from 'fp-ts/lib/Either'; import { strictKeysRt } from './'; import { jsonRt } from '../json_rt'; +import { PathReporter } from 'io-ts/lib/PathReporter'; describe('strictKeysRt', () => { it('correctly and deeply validates object keys', () => { + const metricQueryRt = t.union( + [ + t.type({ + avg_over_time: t.intersection([ + t.type({ + field: t.string, + }), + t.partial({ + range: t.string, + }), + ]), + }), + t.type({ + count_over_time: t.strict({}), + }), + ], + 'metric_query' + ); + + const metricExpressionRt = t.type( + { + expression: t.string, + }, + 'metric_expression' + ); + + const metricRt = t.intersection([ + t.partial({ + record: t.boolean, + }), + t.union([metricQueryRt, metricExpressionRt]), + ]); + + const metricContainerRt = t.record(t.string, metricRt); + + const groupingRt = t.type( + { + by: t.record( + t.string, + t.type({ + field: t.string, + }), + 'by' + ), + limit: t.number, + }, + 'grouping' + ); + + const queryRt = t.intersection( + [ + t.union([groupingRt, t.strict({})]), + t.type({ + index: t.union([t.string, t.array(t.string)]), + metrics: metricContainerRt, + }), + t.partial({ + filter: t.string, + round: t.string, + runtime_mappings: t.string, + query_delay: t.string, + }), + ], + 'query' + ); + const checks: Array<{ type: t.Type; passes: any[]; fails: any[] }> = [ { type: t.intersection([t.type({ foo: t.string }), t.partial({ bar: t.string })]), @@ -42,6 +109,78 @@ describe('strictKeysRt', () => { passes: [{ query: { bar: '', _inspect: true } }], fails: [{ query: { _inspect: true } }], }, + { + type: t.type({ + body: t.intersection([ + t.partial({ + from: t.string, + }), + t.type({ + config: t.intersection([ + t.partial({ + from: t.string, + }), + t.type({ + alert: t.type({}), + }), + t.union([ + t.type({ + query: queryRt, + }), + t.type({ + queries: t.array(queryRt), + }), + ]), + ]), + }), + ]), + }), + passes: [ + { + body: { + config: { + alert: {}, + query: { + index: ['apm-*'], + filter: 'processor.event:transaction', + metrics: { + avg_latency_1h: { + avg_over_time: { + field: 'transaction.duration.us', + }, + }, + }, + }, + }, + }, + }, + ], + fails: [ + { + body: { + config: { + alert: {}, + query: { + index: '', + metrics: { + avg_latency_1h: { + avg_over_time: { + field: '', + range: '', + }, + }, + rate_1h: { + count_over_time: { + field: '', + }, + }, + }, + }, + }, + }, + }, + ], + }, ]; checks.forEach((check) => { @@ -54,9 +193,9 @@ describe('strictKeysRt', () => { if (!isRight(result)) { throw new Error( - `Expected ${JSON.stringify(value)} to be allowed, but validation failed with ${ - result.left[0].message - }` + `Expected ${JSON.stringify( + value + )} to be allowed, but validation failed with ${PathReporter.report(result).join('\n')}` ); } }); diff --git a/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts index 56afdf54463f7..cb3d9bb2100d0 100644 --- a/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts @@ -7,9 +7,9 @@ */ import * as t from 'io-ts'; -import { either, isRight } from 'fp-ts/lib/Either'; -import { mapValues, difference, isPlainObject, forEach } from 'lodash'; -import { MergeType, mergeRt } from '../merge_rt'; +import { either } from 'fp-ts/lib/Either'; +import { difference, isPlainObject, forEach } from 'lodash'; +import { MergeType } from '../merge_rt'; /* Type that tracks validated keys, and fails when the input value @@ -17,153 +17,108 @@ import { MergeType, mergeRt } from '../merge_rt'; */ type ParsableType = - | t.IntersectionType - | t.UnionType + | t.IntersectionType + | t.UnionType | t.PartialType - | t.ExactType + | t.ExactType | t.InterfaceType - | MergeType; - -function getKeysInObject>( - object: T, - prefix: string = '' -): string[] { - const keys: string[] = []; - forEach(object, (value, key) => { - const ownPrefix = prefix ? `${prefix}.${key}` : key; - keys.push(ownPrefix); - if (isPlainObject(object[key])) { - keys.push(...getKeysInObject(object[key] as Record, ownPrefix)); - } - }); - return keys; + | MergeType + | t.DictionaryType; + +const tags = [ + 'DictionaryType', + 'IntersectionType', + 'MergeType', + 'InterfaceType', + 'PartialType', + 'ExactType', + 'UnionType', +]; + +function isParsableType(type: t.Mixed): type is ParsableType { + return tags.includes((type as any)._tag); } -function addToContextWhenValidated | t.PartialType>( - type: T, - prefix: string -): T { - const validate = (input: unknown, context: t.Context) => { - const result = type.validate(input, context); - const keysType = context[0].type as StrictKeysType; - if (!('trackedKeys' in keysType)) { - throw new Error('Expected a top-level StrictKeysType'); - } - if (isRight(result)) { - keysType.trackedKeys.push(...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`)); - } - return result; - }; - - if (type._tag === 'InterfaceType') { - return new t.InterfaceType(type.name, type.is, validate, type.encode, type.props) as T; +function getHandlingTypes(type: t.Mixed, key: string, value: object): t.Mixed[] { + if (!isParsableType(type)) { + return []; } - return new t.PartialType(type.name, type.is, validate, type.encode, type.props) as T; -} + switch (type._tag) { + case 'DictionaryType': + return [type.codomain]; -function trackKeysOfValidatedTypes(type: ParsableType | t.Any, prefix: string = ''): t.Any { - if (!('_tag' in type)) { - return type; - } - const taggedType = type as ParsableType; - - switch (taggedType._tag) { - case 'IntersectionType': { - const collectionType = type as t.IntersectionType; - return t.intersection( - collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] - ); - } + case 'IntersectionType': + return type.types.map((i) => getHandlingTypes(i, key, value)).flat(); - case 'UnionType': { - const collectionType = type as t.UnionType; - return t.union( - collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] - ); - } + case 'MergeType': + return type.types.map((i) => getHandlingTypes(i, key, value)).flat(); - case 'MergeType': { - const collectionType = type as MergeType; - return mergeRt( - ...(collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [ - t.Any, - t.Any - ]) - ); - } + case 'InterfaceType': + case 'PartialType': + return [type.props[key]]; - case 'PartialType': { - const propsType = type as t.PartialType; - - return addToContextWhenValidated( - t.partial( - mapValues(propsType.props, (val, key) => - trackKeysOfValidatedTypes(val, `${prefix}${key}.`) - ) - ), - prefix - ); - } + case 'ExactType': + return getHandlingTypes(type.type, key, value); - case 'InterfaceType': { - const propsType = type as t.InterfaceType; - - return addToContextWhenValidated( - t.type( - mapValues(propsType.props, (val, key) => - trackKeysOfValidatedTypes(val, `${prefix}${key}.`) - ) - ), - prefix - ); - } + case 'UnionType': + const matched = type.types.find((m) => m.is(value)); + return matched ? getHandlingTypes(matched, key, value) : []; + } +} - case 'ExactType': { - const exactType = type as t.ExactType; +function getHandledKeys>( + type: t.Mixed, + object: T, + prefix: string = '' +): { handled: Set; all: Set } { + const keys: { + handled: Set; + all: Set; + } = { + handled: new Set(), + all: new Set(), + }; - return t.exact(trackKeysOfValidatedTypes(exactType.type, prefix) as t.HasProps); + forEach(object, (value, key) => { + const ownPrefix = prefix ? `${prefix}.${key}` : key; + keys.all.add(ownPrefix); + + const handlingTypes = getHandlingTypes(type, key, object).filter(Boolean); + + if (handlingTypes.length) { + keys.handled.add(ownPrefix); } - default: - return type; - } -} + if (isPlainObject(value)) { + handlingTypes.forEach((i) => { + const nextKeys = getHandledKeys(i, value as Record, ownPrefix); + nextKeys.all.forEach((k) => keys.all.add(k)); + nextKeys.handled.forEach((k) => keys.handled.add(k)); + }); + } + }); -class StrictKeysType< - A = any, - O = A, - I = any, - T extends t.Type = t.Type -> extends t.Type { - trackedKeys: string[]; - - constructor(type: T) { - const trackedType = trackKeysOfValidatedTypes(type); - - super( - 'strict_keys', - trackedType.is, - (input, context) => { - this.trackedKeys.length = 0; - return either.chain(trackedType.validate(input, context), (i) => { - const originalKeys = getKeysInObject(input as Record); - const excessKeys = difference(originalKeys, this.trackedKeys); - - if (excessKeys.length) { - return t.failure(i, context, `Excess keys are not allowed: \n${excessKeys.join('\n')}`); - } - - return t.success(i); - }); - }, - trackedType.encode - ); - - this.trackedKeys = []; - } + return keys; } -export function strictKeysRt(type: T): T { - return (new StrictKeysType(type) as unknown) as T; +export function strictKeysRt(type: T) { + return new t.Type( + type.name, + type.is, + (input, context) => { + return either.chain(type.validate(input, context), (i) => { + const keys = getHandledKeys(type, input as Record); + + const excessKeys = difference([...keys.all], [...keys.handled]); + + if (excessKeys.length) { + return t.failure(i, context, `Excess keys are not allowed: \n${excessKeys.join('\n')}`); + } + + return t.success(i); + }); + }, + type.encode + ); } diff --git a/packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts b/packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts new file mode 100644 index 0000000000000..cac7d3b2aae5e --- /dev/null +++ b/packages/kbn-io-ts-utils/src/to_json_schema/index.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { toJsonSchema } from './'; + +describe('toJsonSchema', () => { + it('converts simple types to JSON schema', () => { + expect( + toJsonSchema( + t.type({ + foo: t.string, + }) + ) + ).toEqual({ + type: 'object', + properties: { + foo: { + type: 'string', + }, + }, + required: ['foo'], + }); + + expect( + toJsonSchema( + t.type({ + foo: t.union([t.boolean, t.string]), + }) + ) + ).toEqual({ + type: 'object', + properties: { + foo: { + anyOf: [{ type: 'boolean' }, { type: 'string' }], + }, + }, + required: ['foo'], + }); + }); + + it('converts record/dictionary types', () => { + expect( + toJsonSchema( + t.record( + t.string, + t.intersection([t.type({ foo: t.string }), t.partial({ bar: t.array(t.boolean) })]) + ) + ) + ).toEqual({ + type: 'object', + additionalProperties: { + allOf: [ + { type: 'object', properties: { foo: { type: 'string' } }, required: ['foo'] }, + { type: 'object', properties: { bar: { type: 'array', items: { type: 'boolean' } } } }, + ], + }, + }); + }); +}); diff --git a/packages/kbn-io-ts-utils/src/to_json_schema/index.ts b/packages/kbn-io-ts-utils/src/to_json_schema/index.ts new file mode 100644 index 0000000000000..fc196a7c3123e --- /dev/null +++ b/packages/kbn-io-ts-utils/src/to_json_schema/index.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { mapValues } from 'lodash'; + +type JSONSchemableValueType = + | t.StringType + | t.NumberType + | t.BooleanType + | t.ArrayType + | t.RecordC + | t.DictionaryType + | t.InterfaceType + | t.PartialType + | t.UnionType + | t.IntersectionType; + +const tags = [ + 'StringType', + 'NumberType', + 'BooleanType', + 'ArrayType', + 'DictionaryType', + 'InterfaceType', + 'PartialType', + 'UnionType', + 'IntersectionType', +]; + +const isSchemableValueType = (type: t.Mixed): type is JSONSchemableValueType => { + // @ts-ignore + return tags.includes(type._tag); +}; + +interface JSONSchemaObject { + type: 'object'; + required?: string[]; + properties?: Record; + additionalProperties?: boolean | JSONSchema; +} + +interface JSONSchemaOneOf { + oneOf: JSONSchema[]; +} + +interface JSONSchemaAllOf { + allOf: JSONSchema[]; +} + +interface JSONSchemaAnyOf { + anyOf: JSONSchema[]; +} + +interface JSONSchemaArray { + type: 'array'; + items?: JSONSchema; +} + +interface BaseJSONSchema { + type: string; +} + +type JSONSchema = + | JSONSchemaObject + | JSONSchemaArray + | BaseJSONSchema + | JSONSchemaOneOf + | JSONSchemaAllOf + | JSONSchemaAnyOf; + +export const toJsonSchema = (type: t.Mixed): JSONSchema => { + if (isSchemableValueType(type)) { + switch (type._tag) { + case 'ArrayType': + return { type: 'array', items: toJsonSchema(type.type) }; + + case 'BooleanType': + return { type: 'boolean' }; + + case 'DictionaryType': + return { type: 'object', additionalProperties: toJsonSchema(type.codomain) }; + + case 'InterfaceType': + return { + type: 'object', + properties: mapValues(type.props, toJsonSchema), + required: Object.keys(type.props), + }; + + case 'PartialType': + return { type: 'object', properties: mapValues(type.props, toJsonSchema) }; + + case 'UnionType': + return { anyOf: type.types.map(toJsonSchema) }; + + case 'IntersectionType': + return { allOf: type.types.map(toJsonSchema) }; + + case 'NumberType': + return { type: 'number' }; + + case 'StringType': + return { type: 'string' }; + } + } + + return { + type: 'object', + }; +}; diff --git a/packages/kbn-monaco/src/monaco_imports.ts b/packages/kbn-monaco/src/monaco_imports.ts index 92ea23347c374..3f689e6ec0c01 100644 --- a/packages/kbn-monaco/src/monaco_imports.ts +++ b/packages/kbn-monaco/src/monaco_imports.ts @@ -7,7 +7,6 @@ */ /* eslint-disable @kbn/eslint/module_migration */ - import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import 'monaco-editor/esm/vs/base/common/worker/simpleWorker'; @@ -23,4 +22,7 @@ import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/bracketMatching.js'; // Needed for brackets matching highlight +import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js'; // Needed for basic javascript support +import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution.js'; // Needed for basic xml support + export { monaco }; diff --git a/packages/kbn-optimizer/BUILD.bazel b/packages/kbn-optimizer/BUILD.bazel new file mode 100644 index 0000000000000..3809c2b33d500 --- /dev/null +++ b/packages/kbn-optimizer/BUILD.bazel @@ -0,0 +1,120 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-optimizer" +PKG_REQUIRE_NAME = "@kbn/optimizer" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*", + "**/__fixtures__/**", + "**/__snapshots__/**", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "limits.yml", + "package.json", + "postcss.config.js", + "README.md" +] + +SRC_DEPS = [ + "//packages/kbn-config", + "//packages/kbn-dev-utils", + "//packages/kbn-std", + "//packages/kbn-ui-shared-deps", + "//packages/kbn-utils", + "@npm//chalk", + "@npm//clean-webpack-plugin", + "@npm//compression-webpack-plugin", + "@npm//cpy", + "@npm//del", + "@npm//execa", + "@npm//jest-diff", + "@npm//json-stable-stringify", + "@npm//lmdb-store", + "@npm//loader-utils", + "@npm//node-sass", + "@npm//normalize-path", + "@npm//pirates", + "@npm//resize-observer-polyfill", + "@npm//rxjs", + "@npm//source-map-support", + "@npm//watchpack", + "@npm//webpack", + "@npm//webpack-merge", + "@npm//webpack-sources", + "@npm//zlib" +] + +TYPES_DEPS = [ + "@npm//@types/compression-webpack-plugin", + "@npm//@types/jest", + "@npm//@types/json-stable-stringify", + "@npm//@types/loader-utils", + "@npm//@types/node", + "@npm//@types/normalize-path", + "@npm//@types/source-map-support", + "@npm//@types/watchpack", + "@npm//@types/webpack", + "@npm//@types/webpack-merge", + "@npm//@types/webpack-sources", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 9adc075a7983f..c6960621359c7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -34,7 +34,7 @@ pageLoadAssetSize: indexManagement: 140608 indexPatternManagement: 28222 infra: 184320 - fleet: 450005 + fleet: 465774 ingestPipelines: 58003 inputControlVis: 172675 inspector: 148711 @@ -67,7 +67,7 @@ pageLoadAssetSize: searchprofiler: 67080 security: 95864 securityOss: 30806 - securitySolution: 76000 + securitySolution: 217673 share: 99061 snapshotRestore: 79032 spaces: 57868 @@ -107,7 +107,7 @@ pageLoadAssetSize: dataVisualizer: 27530 banners: 17946 mapsEms: 26072 - timelines: 28613 + timelines: 230410 screenshotMode: 17856 visTypePie: 35583 cases: 144442 diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index a6c8284ad15f6..d23512f7c418d 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -4,10 +4,5 @@ "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", - "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "types": "./target/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index c175979f0e820..1f1e33d3dda7c 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -123,7 +123,7 @@ exports[`prepares assets for distribution: metrics.json 1`] = ` \\"group\\": \\"page load bundle size\\", \\"id\\": \\"foo\\", \\"value\\": 4627, - \\"limitConfigPath\\": \\"packages/kbn-optimizer/limits.yml\\" + \\"limitConfigPath\\": \\"node_modules/@kbn/optimizer/limits.yml\\" }, { \\"group\\": \\"async chunks size\\", diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts index 832fd812d36bb..335a4fd7f74c3 100644 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts +++ b/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts @@ -79,7 +79,6 @@ describe('getOptimizerCacheKey()', () => { await expect(getOptimizerCacheKey(config)).resolves.toMatchInlineSnapshot(` Object { - "bootstrap": "", "deletedPaths": Array [ "/foo/bar/c", ], diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.ts index e622b3b8f593e..da06b76327a8b 100644 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.ts +++ b/packages/kbn-optimizer/src/optimizer/cache_keys.ts @@ -8,7 +8,6 @@ import Path from 'path'; import Fs from 'fs'; -import { promisify } from 'util'; import Chalk from 'chalk'; import execa from 'execa'; @@ -23,8 +22,7 @@ import { getMtimes } from './get_mtimes'; import { getChanges } from './get_changes'; import { OptimizerConfig } from './optimizer_config'; -const OPTIMIZER_DIR = Path.dirname(require.resolve('../../package.json')); -const RELATIVE_DIR = Path.relative(REPO_ROOT, OPTIMIZER_DIR); +const RELATIVE_DIR = 'packages/kbn-optimizer'; export function diffCacheKey(expected?: unknown, actual?: unknown) { const expectedJson = jsonStable(expected, { @@ -114,17 +112,12 @@ export function reformatJestDiff(diff: string | null) { export interface OptimizerCacheKey { readonly lastCommit: string | undefined; - readonly bootstrap: string | undefined; readonly workerConfig: CacheableWorkerConfig; readonly deletedPaths: string[]; readonly modifiedTimes: Record; } async function getLastCommit() { - if (!Fs.existsSync(Path.join(REPO_ROOT, '.git'))) { - return undefined; - } - const { stdout } = await execa( 'git', ['log', '-n', '1', '--pretty=format:%H', '--', RELATIVE_DIR], @@ -136,25 +129,19 @@ async function getLastCommit() { return stdout.trim() || undefined; } -async function getBootstrapCacheKey() { - try { - return await promisify(Fs.readFile)( - Path.resolve(OPTIMIZER_DIR, 'target/.bootstrap-cache'), - 'utf8' - ); - } catch (error) { - if (error?.code !== 'ENOENT') { - throw error; - } - return undefined; +export async function getOptimizerCacheKey(config: OptimizerConfig): Promise { + if (!Fs.existsSync(Path.resolve(REPO_ROOT, '.git'))) { + return { + lastCommit: undefined, + modifiedTimes: {}, + workerConfig: config.getCacheableWorkerConfig(), + deletedPaths: [], + }; } -} -export async function getOptimizerCacheKey(config: OptimizerConfig) { - const [changes, lastCommit, bootstrap] = await Promise.all([ - getChanges(OPTIMIZER_DIR), + const [changes, lastCommit] = await Promise.all([ + getChanges(RELATIVE_DIR), getLastCommit(), - getBootstrapCacheKey(), ] as const); const deletedPaths: string[] = []; @@ -165,7 +152,6 @@ export async function getOptimizerCacheKey(config: OptimizerConfig) { const cacheKeys: OptimizerCacheKey = { lastCommit, - bootstrap, deletedPaths, modifiedTimes: {} as Record, workerConfig: config.getCacheableWorkerConfig(), diff --git a/packages/kbn-optimizer/src/optimizer/get_changes.test.ts b/packages/kbn-optimizer/src/optimizer/get_changes.test.ts index d8be1917c101b..d3cc5cceefddf 100644 --- a/packages/kbn-optimizer/src/optimizer/get_changes.test.ts +++ b/packages/kbn-optimizer/src/optimizer/get_changes.test.ts @@ -7,22 +7,22 @@ */ jest.mock('execa'); -jest.mock('fs'); import { getChanges } from './get_changes'; +import { REPO_ROOT, createAbsolutePathSerializer } from '@kbn/dev-utils'; const execa: jest.Mock = jest.requireMock('execa'); +expect.addSnapshotSerializer(createAbsolutePathSerializer()); + it('parses git ls-files output', async () => { expect.assertions(4); - jest.requireMock('fs').existsSync.mockImplementation(() => true); - execa.mockImplementation((cmd, args, options) => { expect(cmd).toBe('git'); - expect(args).toEqual(['ls-files', '-dmt', '--', '/foo/bar/x']); + expect(args).toEqual(['ls-files', '-dmt', '--', 'foo/bar/x']); expect(options).toEqual({ - cwd: '/foo/bar/x', + cwd: REPO_ROOT, }); return { @@ -37,12 +37,14 @@ it('parses git ls-files output', async () => { }; }); - await expect(getChanges('/foo/bar/x')).resolves.toMatchInlineSnapshot(` + const changes = await getChanges('foo/bar/x'); + + expect(changes).toMatchInlineSnapshot(` Map { - "/foo/bar/x/kbn-optimizer/package.json" => "modified", - "/foo/bar/x/kbn-optimizer/src/common/bundle.ts" => "modified", - "/foo/bar/x/kbn-optimizer/src/common/bundles.ts" => "deleted", - "/foo/bar/x/kbn-optimizer/src/get_bundle_definitions.test.ts" => "deleted", + /kbn-optimizer/package.json => "modified", + /kbn-optimizer/src/common/bundle.ts => "modified", + /kbn-optimizer/src/common/bundles.ts => "deleted", + /kbn-optimizer/src/get_bundle_definitions.test.ts => "deleted", } `); }); diff --git a/packages/kbn-optimizer/src/optimizer/get_changes.ts b/packages/kbn-optimizer/src/optimizer/get_changes.ts index 73fa5e9d931e7..c5f8abe99c322 100644 --- a/packages/kbn-optimizer/src/optimizer/get_changes.ts +++ b/packages/kbn-optimizer/src/optimizer/get_changes.ts @@ -9,22 +9,19 @@ import Path from 'path'; import execa from 'execa'; -import fs from 'fs'; + +import { REPO_ROOT } from '@kbn/dev-utils'; export type Changes = Map; /** * get the changes in all the context directories (plugin public paths) */ -export async function getChanges(dir: string) { +export async function getChanges(relativeDir: string) { const changes: Changes = new Map(); - if (!fs.existsSync(Path.join(dir, '.git'))) { - return changes; - } - - const { stdout } = await execa('git', ['ls-files', '-dmt', '--', dir], { - cwd: dir, + const { stdout } = await execa('git', ['ls-files', '-dmt', '--', relativeDir], { + cwd: REPO_ROOT, }); const output = stdout.trim(); @@ -32,7 +29,7 @@ export async function getChanges(dir: string) { if (output) { for (const line of output.split('\n')) { const [tag, ...pathParts] = line.trim().split(' '); - const path = Path.resolve(dir, pathParts.join(' ')); + const path = Path.resolve(REPO_ROOT, pathParts.join(' ')); switch (tag) { case 'M': case 'C': diff --git a/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts b/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts index 92875d3f69e46..d9e1bee22557b 100644 --- a/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts +++ b/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts @@ -79,7 +79,7 @@ export class BundleMetricsPlugin { id: bundle.id, value: entry.size, limit: bundle.pageLoadAssetSizeLimit, - limitConfigPath: `packages/kbn-optimizer/limits.yml`, + limitConfigPath: `node_modules/@kbn/optimizer/limits.yml`, }, { group: `async chunks size`, diff --git a/packages/kbn-optimizer/tsconfig.json b/packages/kbn-optimizer/tsconfig.json index f2d508cf14a55..76beaf7689fd4 100644 --- a/packages/kbn-optimizer/tsconfig.json +++ b/packages/kbn-optimizer/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-optimizer/src" }, diff --git a/packages/kbn-plugin-helpers/BUILD.bazel b/packages/kbn-plugin-helpers/BUILD.bazel new file mode 100644 index 0000000000000..1a1f3453f768a --- /dev/null +++ b/packages/kbn-plugin-helpers/BUILD.bazel @@ -0,0 +1,97 @@ + +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-plugin-helpers" +PKG_REQUIRE_NAME = "@kbn/plugin-helpers" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md" +] + +SRC_DEPS = [ + "//packages/kbn-dev-utils", + "//packages/kbn-optimizer", + "//packages/kbn-utils", + "@npm//del", + "@npm//execa", + "@npm//extract-zip", + "@npm//globby", + "@npm//gulp-zip", + "@npm//inquirer", + "@npm//load-json-file", + "@npm//vinyl-fs", +] + +TYPES_DEPS = [ + "@npm//@types/extract-zip", + "@npm//@types/gulp-zip", + "@npm//@types/inquirer", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/vinyl-fs", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index 2d642d9ede13b..1f4df52a03304 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -11,12 +11,5 @@ "types": "target/index.d.ts", "bin": { "plugin-helpers": "bin/plugin-helpers.js" - }, - "scripts": { - "kbn:bootstrap": "rm -rf target && ../../node_modules/.bin/tsc", - "kbn:watch": "../../node_modules/.bin/tsc --watch" - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-plugin-helpers/tsconfig.json b/packages/kbn-plugin-helpers/tsconfig.json index 87d11843f398a..4348f1e1a7516 100644 --- a/packages/kbn-plugin-helpers/tsconfig.json +++ b/packages/kbn-plugin-helpers/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "target", "target": "ES2018", "declaration": true, diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index e455f487d1384..5be9dff630ed5 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -63827,6 +63827,7 @@ function getProjectPaths({ projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/plugin_functional/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/interpreter_functional/plugins/*')); + projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'test/server_integration/__fixtures__/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'examples/*')); if (!ossOnly) { diff --git a/packages/kbn-pm/src/config.ts b/packages/kbn-pm/src/config.ts index a11b2ad9c72c3..666a2fed7a33c 100644 --- a/packages/kbn-pm/src/config.ts +++ b/packages/kbn-pm/src/config.ts @@ -31,6 +31,7 @@ export function getProjectPaths({ rootPath, ossOnly, skipKibanaPlugins }: Option // correct and the expect behavior. projectPaths.push(resolve(rootPath, 'test/plugin_functional/plugins/*')); projectPaths.push(resolve(rootPath, 'test/interpreter_functional/plugins/*')); + projectPaths.push(resolve(rootPath, 'test/server_integration/__fixtures__/plugins/*')); projectPaths.push(resolve(rootPath, 'examples/*')); if (!ossOnly) { diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts index f75f0dcebf4f6..1909bcb1bcc2e 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts @@ -42,6 +42,7 @@ export interface UseExceptionListsProps { notifications: NotificationsStart; pagination?: Pagination; showTrustedApps: boolean; + showEventFilters: boolean; } export interface UseExceptionListProps { diff --git a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts index a9a93aa8df49a..0bd4c6c705668 100644 --- a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts +++ b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts @@ -28,6 +28,7 @@ export type ReturnExceptionLists = [boolean, ExceptionListSchema[], Pagination, * @param namespaceTypes spaces to be searched * @param notifications kibana service for displaying toasters * @param showTrustedApps boolean - include/exclude trusted app lists + * @param showEventFilters boolean - include/exclude event filters lists * @param pagination * */ @@ -43,6 +44,7 @@ export const useExceptionLists = ({ namespaceTypes, notifications, showTrustedApps = false, + showEventFilters = false, }: UseExceptionListsProps): ReturnExceptionLists => { const [exceptionLists, setExceptionLists] = useState([]); const [paginationInfo, setPagination] = useState(pagination); @@ -51,8 +53,9 @@ export const useExceptionLists = ({ const namespaceTypesAsString = useMemo(() => namespaceTypes.join(','), [namespaceTypes]); const filters = useMemo( - (): string => getFilters(filterOptions, namespaceTypes, showTrustedApps), - [namespaceTypes, filterOptions, showTrustedApps] + (): string => + getFilters({ filters: filterOptions, namespaceTypes, showTrustedApps, showEventFilters }), + [namespaceTypes, filterOptions, showTrustedApps, showEventFilters] ); useEffect(() => { diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts new file mode 100644 index 0000000000000..934a9cbff56a6 --- /dev/null +++ b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getEventFiltersFilter } from '.'; + +describe('getEventFiltersFilter', () => { + test('it returns filter to search for "exception-list" namespace trusted apps', () => { + const filter = getEventFiltersFilter(true, ['exception-list']); + + expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_event_filters*)'); + }); + + test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { + const filter = getEventFiltersFilter(true, ['exception-list', 'exception-list-agnostic']); + + expect(filter).toEqual( + '(exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it returns filter to exclude "exception-list" namespace trusted apps', () => { + const filter = getEventFiltersFilter(false, ['exception-list']); + + expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_event_filters*)'); + }); + + test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { + const filter = getEventFiltersFilter(false, ['exception-list', 'exception-list-agnostic']); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); +}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts new file mode 100644 index 0000000000000..7e55073228fca --- /dev/null +++ b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { SavedObjectType } from '../types'; + +export const getEventFiltersFilter = ( + showEventFilter: boolean, + namespaceTypes: SavedObjectType[] +): string => { + if (showEventFilter) { + const filters = namespaceTypes.map((namespace) => { + return `${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; + }); + return `(${filters.join(' OR ')})`; + } else { + const filters = namespaceTypes.map((namespace) => { + return `not ${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; + }); + return `(${filters.join(' AND ')})`; + } +}; diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts index 327a29dc1b987..bfaad52ee8147 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts @@ -11,106 +11,318 @@ import { getFilters } from '.'; describe('getFilters', () => { describe('single', () => { test('it properly formats when no filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({}, ['single'], false); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: false, + }); - expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)'); + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); }); test('it properly formats when no filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({}, ['single'], true); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single'], + showTrustedApps: true, + showEventFilters: false, + }); - expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)'); + expect(filter).toEqual( + '(exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); }); test('it properly formats when filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], false); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' ); }); test('it if filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], true); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single'], + showTrustedApps: true, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: true, + }); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it if filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single'], + showTrustedApps: false, + showEventFilters: true, + }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*)' ); }); }); describe('agnostic', () => { test('it properly formats when no filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({}, ['agnostic'], false); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when no filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({}, ['agnostic'], true); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['agnostic'], + showTrustedApps: true, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], false); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it if filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], true); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['agnostic'], + showTrustedApps: true, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: true, + }); + + expect(filter).toEqual( + '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it if filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['agnostic'], + showTrustedApps: false, + showEventFilters: true, + }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); }); describe('single, agnostic', () => { test('it properly formats when no filters passed and "showTrustedApps" is false', () => { - const filter = getFilters({}, ['single', 'agnostic'], false); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when no filters passed and "showTrustedApps" is true', () => { - const filter = getFilters({}, ['single', 'agnostic'], true); + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: true, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when filters passed and "showTrustedApps" is false', () => { - const filter = getFilters( - { created_by: 'moi', name: 'Sample' }, - ['single', 'agnostic'], - false - ); + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); test('it properly formats when filters passed and "showTrustedApps" is true', () => { - const filter = getFilters( - { created_by: 'moi', name: 'Sample' }, - ['single', 'agnostic'], - true + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: true, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when no filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: {}, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: true, + }); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' + ); + }); + + test('it properly formats when filters passed and "showEventFilters" is false', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: false, + }); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); + }); + + test('it properly formats when filters passed and "showEventFilters" is true', () => { + const filter = getFilters({ + filters: { created_by: 'moi', name: 'Sample' }, + namespaceTypes: ['single', 'agnostic'], + showTrustedApps: false, + showEventFilters: true, + }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' ); }); }); diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts index c9dd6ccae484c..238ae5541343c 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts @@ -10,14 +10,26 @@ import { ExceptionListFilter, NamespaceType } from '@kbn/securitysolution-io-ts- import { getGeneralFilters } from '../get_general_filters'; import { getSavedObjectTypes } from '../get_saved_object_types'; import { getTrustedAppsFilter } from '../get_trusted_apps_filter'; +import { getEventFiltersFilter } from '../get_event_filters_filter'; -export const getFilters = ( - filters: ExceptionListFilter, - namespaceTypes: NamespaceType[], - showTrustedApps: boolean -): string => { +export interface GetFiltersParams { + filters: ExceptionListFilter; + namespaceTypes: NamespaceType[]; + showTrustedApps: boolean; + showEventFilters: boolean; +} + +export const getFilters = ({ + filters, + namespaceTypes, + showTrustedApps, + showEventFilters, +}: GetFiltersParams): string => { const namespaces = getSavedObjectTypes({ namespaceType: namespaceTypes }); const generalFilters = getGeneralFilters(filters, namespaces); const trustedAppsFilter = getTrustedAppsFilter(showTrustedApps, namespaces); - return [generalFilters, trustedAppsFilter].filter((filter) => filter.trim() !== '').join(' AND '); + const eventFiltersFilter = getEventFiltersFilter(showEventFilters, namespaces); + return [generalFilters, trustedAppsFilter, eventFiltersFilter] + .filter((filter) => filter.trim() !== '') + .join(' AND '); }; diff --git a/packages/kbn-securitysolution-t-grid/BUILD.bazel b/packages/kbn-securitysolution-t-grid/BUILD.bazel new file mode 100644 index 0000000000000..5cf1081bdd32e --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/BUILD.bazel @@ -0,0 +1,125 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-securitysolution-t-grid" + +PKG_REQUIRE_NAME = "@kbn/securitysolution-t-grid" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + ], + exclude = [ + "**/*.test.*", + "**/*.mock.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "react/package.json", + "package.json", + "README.md", +] + +SRC_DEPS = [ + "//packages/kbn-babel-preset", + "//packages/kbn-dev-utils", + "//packages/kbn-i18n", + "@npm//@babel/core", + "@npm//babel-loader", + "@npm//enzyme", + "@npm//jest", + "@npm//lodash", + "@npm//react", + "@npm//react-beautiful-dnd", + "@npm//tslib", +] + +TYPES_DEPS = [ + "@npm//typescript", + "@npm//@types/enzyme", + "@npm//@types/jest", + "@npm//@types/lodash", + "@npm//@types/node", + "@npm//@types/react", + "@npm//@types/react-beautiful-dnd", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_config( + name = "tsconfig_browser", + src = "tsconfig.browser.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.browser.json", + ], +) + +ts_project( + name = "tsc", + args = ["--pretty"], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_dir = "target_types", + declaration_map = True, + incremental = True, + out_dir = "target_node", + root_dir = "src", + source_map = True, + tsconfig = ":tsconfig", +) + +ts_project( + name = "tsc_browser", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + allow_js = True, + declaration = False, + incremental = True, + out_dir = "target_web", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig_browser", +) + +js_library( + name = PKG_BASE_NAME, + package_name = PKG_REQUIRE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + visibility = ["//visibility:public"], + deps = [":tsc", ":tsc_browser"] + DEPS, +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ], +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-securitysolution-t-grid/README.md b/packages/kbn-securitysolution-t-grid/README.md new file mode 100644 index 0000000000000..a49669c81689a --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/README.md @@ -0,0 +1,3 @@ +# kbn-securitysolution-t-grid + +We do not want to create circular dependencies between security_solution and timelines plugins. Therefore , we will use this packages to share components between these two plugins. diff --git a/packages/kbn-securitysolution-t-grid/babel.config.js b/packages/kbn-securitysolution-t-grid/babel.config.js new file mode 100644 index 0000000000000..b4a118df51af5 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/babel.config.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + env: { + web: { + presets: ['@kbn/babel-preset/webpack_preset'], + }, + node: { + presets: ['@kbn/babel-preset/node_preset'], + }, + }, + ignore: ['**/*.test.ts', '**/*.test.tsx'], +}; diff --git a/packages/kbn-securitysolution-t-grid/jest.config.js b/packages/kbn-securitysolution-t-grid/jest.config.js new file mode 100644 index 0000000000000..21e7d2d71b61a --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-securitysolution-t-grid'], +}; diff --git a/packages/kbn-securitysolution-t-grid/package.json b/packages/kbn-securitysolution-t-grid/package.json new file mode 100644 index 0000000000000..68d3a8c71e7ca --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/package.json @@ -0,0 +1,10 @@ +{ + "name": "@kbn/securitysolution-t-grid", + "version": "1.0.0", + "description": "security solution t-grid packages will allow sharing components between timelines and security_solution plugin until we transfer all functionality to timelines plugin", + "license": "SSPL-1.0 OR Elastic License 2.0", + "browser": "./target_web/browser.js", + "main": "./target_node/index.js", + "types": "./target_types/index.d.ts", + "private": true +} diff --git a/packages/kbn-securitysolution-t-grid/react/package.json b/packages/kbn-securitysolution-t-grid/react/package.json new file mode 100644 index 0000000000000..c29ddd45f084d --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/react/package.json @@ -0,0 +1,5 @@ +{ + "browser": "../target_web/react", + "main": "../target_node/react", + "types": "../target_types/react/index.d.ts" +} \ No newline at end of file diff --git a/packages/kbn-securitysolution-t-grid/src/constants/index.ts b/packages/kbn-securitysolution-t-grid/src/constants/index.ts new file mode 100644 index 0000000000000..c03c0093d9839 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/constants/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const HIGHLIGHTED_DROP_TARGET_CLASS_NAME = 'highlighted-drop-target'; +export const EMPTY_PROVIDERS_GROUP_CLASS_NAME = 'empty-providers-group'; + +/** The draggable will move this many pixels via the keyboard when the arrow key is pressed */ +export const KEYBOARD_DRAG_OFFSET = 20; + +export const DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME = 'draggable-keyboard-wrapper'; + +export const ROW_RENDERER_CLASS_NAME = 'row-renderer'; + +export const NOTES_CONTAINER_CLASS_NAME = 'notes-container'; + +export const NOTE_CONTENT_CLASS_NAME = 'note-content'; + +/** This class is added to the document body while dragging */ +export const IS_DRAGGING_CLASS_NAME = 'is-dragging'; + +export const HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME = 'hover-actions-always-show'; diff --git a/packages/kbn-securitysolution-t-grid/src/index.ts b/packages/kbn-securitysolution-t-grid/src/index.ts new file mode 100644 index 0000000000000..0c2e9a7dbea8b --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './constants'; +export * from './utils'; +export * from './mock'; diff --git a/packages/kbn-interpreter/scripts/build.js b/packages/kbn-securitysolution-t-grid/src/mock/index.ts similarity index 90% rename from packages/kbn-interpreter/scripts/build.js rename to packages/kbn-securitysolution-t-grid/src/mock/index.ts index 21b7f86c6bc34..dc1b63dfc33b0 100644 --- a/packages/kbn-interpreter/scripts/build.js +++ b/packages/kbn-securitysolution-t-grid/src/mock/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -require('../tasks/build/cli'); +export * from './mock_event_details'; diff --git a/x-pack/plugins/security_solution/common/utils/mock_event_details.ts b/packages/kbn-securitysolution-t-grid/src/mock/mock_event_details.ts similarity index 97% rename from x-pack/plugins/security_solution/common/utils/mock_event_details.ts rename to packages/kbn-securitysolution-t-grid/src/mock/mock_event_details.ts index 7dc257ebb3fef..167fc9dd17a2a 100644 --- a/x-pack/plugins/security_solution/common/utils/mock_event_details.ts +++ b/packages/kbn-securitysolution-t-grid/src/mock/mock_event_details.ts @@ -1,8 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ export const eventHit = { diff --git a/packages/kbn-securitysolution-t-grid/src/utils/api/index.ts b/packages/kbn-securitysolution-t-grid/src/utils/api/index.ts new file mode 100644 index 0000000000000..34e448419693b --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/utils/api/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { has } from 'lodash/fp'; + +export interface AppError extends Error { + body: { + message: string; + }; +} + +export interface KibanaError extends AppError { + body: { + message: string; + statusCode: number; + }; +} + +export interface SecurityAppError extends AppError { + body: { + message: string; + status_code: number; + }; +} + +export const isKibanaError = (error: unknown): error is KibanaError => + has('message', error) && has('body.message', error) && has('body.statusCode', error); + +export const isSecurityAppError = (error: unknown): error is SecurityAppError => + has('message', error) && has('body.message', error) && has('body.status_code', error); + +export const isAppError = (error: unknown): error is AppError => + isKibanaError(error) || isSecurityAppError(error); + +export const isNotFoundError = (error: unknown) => + (isKibanaError(error) && error.body.statusCode === 404) || + (isSecurityAppError(error) && error.body.status_code === 404); diff --git a/packages/kbn-securitysolution-t-grid/src/utils/drag_and_drop/index.ts b/packages/kbn-securitysolution-t-grid/src/utils/drag_and_drop/index.ts new file mode 100644 index 0000000000000..91b2e88d97358 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/utils/drag_and_drop/index.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DropResult } from 'react-beautiful-dnd'; + +export const draggableIdPrefix = 'draggableId'; + +export const droppableIdPrefix = 'droppableId'; + +export const draggableContentPrefix = `${draggableIdPrefix}.content.`; + +export const draggableTimelineProvidersPrefix = `${draggableIdPrefix}.timelineProviders.`; + +export const draggableFieldPrefix = `${draggableIdPrefix}.field.`; + +export const droppableContentPrefix = `${droppableIdPrefix}.content.`; + +export const droppableFieldPrefix = `${droppableIdPrefix}.field.`; + +export const droppableTimelineProvidersPrefix = `${droppableIdPrefix}.timelineProviders.`; + +export const droppableTimelineColumnsPrefix = `${droppableIdPrefix}.timelineColumns.`; + +export const droppableTimelineFlyoutBottomBarPrefix = `${droppableIdPrefix}.flyoutButton.`; + +export const getDraggableId = (dataProviderId: string): string => + `${draggableContentPrefix}${dataProviderId}`; + +export const getDraggableFieldId = ({ + contextId, + fieldId, +}: { + contextId: string; + fieldId: string; +}): string => `${draggableFieldPrefix}${escapeContextId(contextId)}.${escapeFieldId(fieldId)}`; + +export const getTimelineProviderDroppableId = ({ + groupIndex, + timelineId, +}: { + groupIndex: number; + timelineId: string; +}): string => `${droppableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}`; + +export const getTimelineProviderDraggableId = ({ + dataProviderId, + groupIndex, + timelineId, +}: { + dataProviderId: string; + groupIndex: number; + timelineId: string; +}): string => + `${draggableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}.${dataProviderId}`; + +export const getDroppableId = (visualizationPlaceholderId: string): string => + `${droppableContentPrefix}${visualizationPlaceholderId}`; + +export const sourceIsContent = (result: DropResult): boolean => + result.source.droppableId.startsWith(droppableContentPrefix); + +export const sourceAndDestinationAreSameTimelineProviders = (result: DropResult): boolean => { + const regex = /^droppableId\.timelineProviders\.(\S+)\./; + const sourceMatches = result.source.droppableId.match(regex) || []; + const destinationMatches = + (result.destination && result.destination.droppableId.match(regex)) || []; + + return ( + sourceMatches.length >= 2 && + destinationMatches.length >= 2 && + sourceMatches[1] === destinationMatches[1] + ); +}; + +export const draggableIsContent = (result: DropResult | { draggableId: string }): boolean => + result.draggableId.startsWith(draggableContentPrefix); + +export const draggableIsField = (result: DropResult | { draggableId: string }): boolean => + result.draggableId.startsWith(draggableFieldPrefix); + +export const reasonIsDrop = (result: DropResult): boolean => result.reason === 'DROP'; + +export const destinationIsTimelineProviders = (result: DropResult): boolean => + result.destination != null && + result.destination.droppableId.startsWith(droppableTimelineProvidersPrefix); + +export const destinationIsTimelineColumns = (result: DropResult): boolean => + result.destination != null && + result.destination.droppableId.startsWith(droppableTimelineColumnsPrefix); + +export const destinationIsTimelineButton = (result: DropResult): boolean => + result.destination != null && + result.destination.droppableId.startsWith(droppableTimelineFlyoutBottomBarPrefix); + +export const getProviderIdFromDraggable = (result: DropResult): string => + result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1); + +export const getFieldIdFromDraggable = (result: DropResult): string => + unEscapeFieldId(result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1)); + +export const escapeDataProviderId = (path: string) => path.replace(/\./g, '_'); + +export const escapeContextId = (path: string) => path.replace(/\./g, '_'); + +export const escapeFieldId = (path: string) => path.replace(/\./g, '!!!DOT!!!'); + +export const unEscapeFieldId = (path: string) => path.replace(/!!!DOT!!!/g, '.'); + +export const providerWasDroppedOnTimeline = (result: DropResult): boolean => + reasonIsDrop(result) && + draggableIsContent(result) && + sourceIsContent(result) && + destinationIsTimelineProviders(result); + +export const userIsReArrangingProviders = (result: DropResult): boolean => + reasonIsDrop(result) && sourceAndDestinationAreSameTimelineProviders(result); + +export const fieldWasDroppedOnTimelineColumns = (result: DropResult): boolean => + reasonIsDrop(result) && draggableIsField(result) && destinationIsTimelineColumns(result); + +/** + * Prevents fields from being dragged or dropped to any area other than column + * header drop zone in the timeline + */ +export const DRAG_TYPE_FIELD = 'drag-type-field'; + +/** This class is added to the document body while timeline field dragging */ +export const IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME = 'is-timeline-field-dragging'; diff --git a/packages/kbn-securitysolution-t-grid/src/utils/index.ts b/packages/kbn-securitysolution-t-grid/src/utils/index.ts new file mode 100644 index 0000000000000..39629a990c539 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/utils/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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './api'; +export * from './drag_and_drop'; diff --git a/packages/kbn-securitysolution-t-grid/tsconfig.browser.json b/packages/kbn-securitysolution-t-grid/tsconfig.browser.json new file mode 100644 index 0000000000000..a5183ba4fd457 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/tsconfig.browser.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.browser.json", + "compilerOptions": { + "allowJs": true, + "incremental": true, + "outDir": "./target_web", + "declaration": false, + "isolatedModules": true, + "sourceMap": true, + "sourceRoot": "../../../../../packages/kbn-securitysolution-t-grid/src", + "types": [ + "jest", + "node" + ], + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + ], + "exclude": [ + "**/__fixtures__/**/*" + ] +} diff --git a/packages/kbn-securitysolution-t-grid/tsconfig.json b/packages/kbn-securitysolution-t-grid/tsconfig.json new file mode 100644 index 0000000000000..8cda578edede4 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "incremental": true, + "outDir": "target", + "rootDir": "src", + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-securitysolution-t-grid/src", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kbn-spec-to-console/BUILD.bazel b/packages/kbn-spec-to-console/BUILD.bazel new file mode 100644 index 0000000000000..8a083be9fce91 --- /dev/null +++ b/packages/kbn-spec-to-console/BUILD.bazel @@ -0,0 +1,55 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-spec-to-console" +PKG_REQUIRE_NAME = "@kbn/spec-to-console" + +SOURCE_FILES = glob( + [ + "bin/**/*", + "lib/**/*", + "index.js" + ], + exclude = [ + "**/*.test.*", + "**/__fixtures__/**", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-spec-to-console/test/fixtures/cluster_health_autocomplete.json b/packages/kbn-spec-to-console/lib/__fixtures__/cluster_health_autocomplete.json similarity index 100% rename from packages/kbn-spec-to-console/test/fixtures/cluster_health_autocomplete.json rename to packages/kbn-spec-to-console/lib/__fixtures__/cluster_health_autocomplete.json diff --git a/packages/kbn-spec-to-console/test/fixtures/cluster_health_spec.json b/packages/kbn-spec-to-console/lib/__fixtures__/cluster_health_spec.json similarity index 100% rename from packages/kbn-spec-to-console/test/fixtures/cluster_health_spec.json rename to packages/kbn-spec-to-console/lib/__fixtures__/cluster_health_spec.json diff --git a/packages/kbn-spec-to-console/lib/convert.test.js b/packages/kbn-spec-to-console/lib/convert.test.js index 6d6b6ba364d38..14cb2dd7b6c04 100644 --- a/packages/kbn-spec-to-console/lib/convert.test.js +++ b/packages/kbn-spec-to-console/lib/convert.test.js @@ -8,8 +8,8 @@ const convert = require('./convert'); -const clusterHealthSpec = require('../test/fixtures/cluster_health_spec'); -const clusterHealthAutocomplete = require('../test/fixtures/cluster_health_autocomplete'); +const clusterHealthSpec = require('./__fixtures__/cluster_health_spec'); +const clusterHealthAutocomplete = require('./__fixtures__/cluster_health_autocomplete'); test('convert', () => { expect(convert(clusterHealthSpec)).toEqual(clusterHealthAutocomplete); diff --git a/packages/kbn-spec-to-console/package.json b/packages/kbn-spec-to-console/package.json index c6cf8cf9ec46d..b4e488db7f4d9 100644 --- a/packages/kbn-spec-to-console/package.json +++ b/packages/kbn-spec-to-console/package.json @@ -1,11 +1,12 @@ { - "name": "spec-to-console", - "version": "0.0.0", + "name": "@kbn/spec-to-console", + "version": "1.0.0", "description": "ES REST spec -> Console autocomplete", "main": "index.js", "directories": { "lib": "lib" }, + "private": true, "scripts": { "format": "../../node_modules/.bin/prettier **/*.js --write" }, diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 225f93d487823..5baff607704c7 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -94,7 +94,7 @@ module.exports = { transformIgnorePatterns: [ // ignore all node_modules except monaco-editor and react-monaco-editor which requires babel transforms to handle dynamic import() // since ESM modules are not natively supported in Jest yet (https://github.com/facebook/jest/issues/4842) - '[/\\\\]node_modules(?![\\/\\\\](monaco-editor|react-monaco-editor))[/\\\\].+\\.js$', + '[/\\\\]node_modules(?![\\/\\\\](monaco-editor|react-monaco-editor|d3-interpolate|d3-color))[/\\\\].+\\.js$', 'packages/kbn-pm/dist/index.js', ], diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json index 275d9fac73c58..aaff513f1591f 100644 --- a/packages/kbn-test/package.json +++ b/packages/kbn-test/package.json @@ -12,8 +12,5 @@ }, "kibana": { "devOnly": true - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-test/src/es/index.ts b/packages/kbn-test/src/es/index.ts index 0770ac82596ff..ccfec67dae848 100644 --- a/packages/kbn-test/src/es/index.ts +++ b/packages/kbn-test/src/es/index.ts @@ -7,4 +7,5 @@ */ export { createTestEsCluster } from './test_es_cluster'; +export type { CreateTestEsClusterOptions, EsTestCluster, ICluster } from './test_es_cluster'; export { esTestConfig } from './es_test_config'; diff --git a/packages/kbn-test/src/es/test_es_cluster.ts b/packages/kbn-test/src/es/test_es_cluster.ts index 658fc9382d616..2d6edce269684 100644 --- a/packages/kbn-test/src/es/test_es_cluster.ts +++ b/packages/kbn-test/src/es/test_es_cluster.ts @@ -19,12 +19,51 @@ import { esTestConfig } from './es_test_config'; import { KIBANA_ROOT } from '../'; -interface TestClusterFactoryOptions { - port?: number; - password?: string; - license?: 'basic' | 'trial'; // | 'oss' +interface TestEsClusterNodesOptions { + name: string; + /** + * Depending on the test you are running, it may be necessary to + * configure a separate data archive for each node in the cluster. + * In that case, you can configure each of the archive paths here. + * + * Specifying a top-level `dataArchive` is not necessary if you are using + * this option; per-node archives will always be used if provided. + */ + dataArchive?: string; +} + +interface Node { + installSource: (opts: Record) => Promise<{ installPath: string }>; + installSnapshot: (opts: Record) => Promise<{ installPath: string }>; + extractDataDirectory: ( + installPath: string, + archivePath: string, + extractDirName?: string + ) => Promise<{ insallPath: string }>; + start: (installPath: string, opts: Record) => Promise; + stop: () => Promise; +} + +export interface ICluster { + ports: number[]; + nodes: Node[]; + getStartTimeout: () => number; + start: () => Promise; + stop: () => Promise; + cleanup: () => Promise; + getClient: () => KibanaClient; + getHostUrls: () => string[]; +} + +export type EsTestCluster< + Options extends CreateTestEsClusterOptions = CreateTestEsClusterOptions +> = Options['nodes'] extends TestEsClusterNodesOptions[] + ? ICluster + : ICluster & { getUrl: () => string }; // Only allow use of `getUrl` if `nodes` option isn't provided. + +export interface CreateTestEsClusterOptions { basePath?: string; - esFrom?: string; + clusterName?: string; /** * Path to data archive snapshot to run Elasticsearch with. * To prepare the the snapshot: @@ -33,16 +72,78 @@ interface TestClusterFactoryOptions { * - stop Elasticsearch server * - go to Elasticsearch folder: cd .es/${ELASTICSEARCH_VERSION} * - archive data folder: zip -r my_archive.zip data - * */ + */ dataArchive?: string; + /** + * Elasticsearch configuration options. These are key/value pairs formatted as: + * `['key.1=val1', 'key.2=val2']` + */ esArgs?: string[]; + esFrom?: string; esJavaOpts?: string; - clusterName?: string; + /** + * License to run your cluster under. Keep in mind that a `trial` license + * has an expiration date. If you are using a `dataArchive` with your tests, + * you'll likely need to use `basic` or `gold` to prevent the test from failing + * when the license expires. + */ + license?: 'basic' | 'gold' | 'trial'; // | 'oss' log: ToolingLog; + /** + * Node-specific configuration if you wish to run a multi-node + * cluster. One node will be added for each item in the array. + * + * If this option is not provided, the config will default + * to a single-node cluster. + * + * @example + * { + * nodes: [ + * { + * name: 'node-01', + * dataArchive: Path.join(__dirname, 'path', 'to', 'data_01') + * . }, + * { + * name: 'node-02', + * dataArchive: Path.join(__dirname, 'path', 'to', 'data_02') + * . }, + * ], + * } + */ + nodes?: TestEsClusterNodesOptions[]; + /** + * Password for the `elastic` user. This is set after the cluster has started. + * + * Defaults to `changeme`. + */ + password?: string; + /** + * Port to run Elasticsearch on. If you configure a + * multi-node cluster with the `nodes` option, this + * port will be incremented by one for each added node. + * + * @example + * { + * nodes: [ + * { + * name: 'node-01', + * dataArchive: Path.join(__dirname, 'path', 'to', 'data_01') + * . }, + * { + * name: 'node-02', + * dataArchive: Path.join(__dirname, 'path', 'to', 'data_02') + * . }, + * ], + * port: 6200, // node-01 will use 6200, node-02 will use 6201 + * } + */ + port?: number; ssl?: boolean; } -export function createTestEsCluster(options: TestClusterFactoryOptions) { +export function createTestEsCluster< + Options extends CreateTestEsClusterOptions = CreateTestEsClusterOptions +>(options: Options): EsTestCluster { const { port = esTestConfig.getPort(), password = 'changeme', @@ -51,6 +152,7 @@ export function createTestEsCluster(options: TestClusterFactoryOptions) { basePath = Path.resolve(KIBANA_ROOT, '.es'), esFrom = esTestConfig.getBuildFrom(), dataArchive, + nodes = [{ name: 'node-01' }], esArgs: customEsArgs = [], esJavaOpts, clusterName: customClusterName = 'es-test-cluster', @@ -59,14 +161,17 @@ export function createTestEsCluster(options: TestClusterFactoryOptions) { const clusterName = `${CI_PARALLEL_PROCESS_PREFIX}${customClusterName}`; - const esArgs = [ + const defaultEsArgs = [ `cluster.name=${clusterName}`, - `http.port=${port}`, - 'discovery.type=single-node', `transport.port=${esTestConfig.getTransportPort()}`, - ...customEsArgs, + // For multi-node clusters, we make all nodes master-eligible by default. + ...(nodes.length > 1 + ? ['discovery.type=zen', `cluster.initial_master_nodes=${nodes.map((n) => n.name).join(',')}`] + : ['discovery.type=single-node']), ]; + const esArgs = assignArgs(defaultEsArgs, customEsArgs); + const config = { version: esTestConfig.getVersion(), installPath: Path.resolve(basePath, clusterName), @@ -77,9 +182,18 @@ export function createTestEsCluster(options: TestClusterFactoryOptions) { esArgs, }; - const cluster = new Cluster({ log, ssl }); + return new (class TestCluster { + ports: number[] = []; + nodes: Node[] = []; + + constructor() { + for (let i = 0; i < nodes.length; i++) { + this.nodes.push(new Cluster({ log, ssl })); + // If this isn't the first node, we increment the port of the last node + this.ports.push(i === 0 ? port : port + i); + } + } - return new (class EsTestCluster { getStartTimeout() { const second = 1000; const minute = second * 60; @@ -88,31 +202,73 @@ export function createTestEsCluster(options: TestClusterFactoryOptions) { } async start() { - let installPath; + let installPath: string; + // We only install once using the first node. If the cluster has + // multiple nodes, they'll all share the same ESinstallation. + const firstNode = this.nodes[0]; if (esFrom === 'source') { - installPath = (await cluster.installSource(config)).installPath; + installPath = (await firstNode.installSource(config)).installPath; } else if (esFrom === 'snapshot') { - installPath = (await cluster.installSnapshot(config)).installPath; + installPath = (await firstNode.installSnapshot(config)).installPath; } else if (Path.isAbsolute(esFrom)) { installPath = esFrom; } else { throw new Error(`unknown option esFrom "${esFrom}"`); } - if (dataArchive) { - await cluster.extractDataDirectory(installPath, dataArchive); + // Collect promises so we can run them in parallel + const extractDirectoryPromises = []; + const nodeStartPromises = []; + + for (let i = 0; i < this.nodes.length; i++) { + const node = nodes[i]; + const nodePort = this.ports[i]; + const overriddenArgs = [`node.name=${node.name}`, `http.port=${nodePort}`]; + + const archive = node.dataArchive || dataArchive; + if (archive) { + extractDirectoryPromises.push(async () => { + const nodeDataDirectory = node.dataArchive ? `data-${node.name}` : 'data'; + overriddenArgs.push(`path.data=${Path.resolve(installPath, nodeDataDirectory)}`); + return await this.nodes[i].extractDataDirectory( + installPath, + archive, + nodeDataDirectory + ); + }); + } + + nodeStartPromises.push(async () => { + log.info(`[es] starting node ${node.name} on port ${nodePort}`); + return await this.nodes[i].start(installPath, { + password: config.password, + esArgs: assignArgs(esArgs, overriddenArgs), + esJavaOpts, + // If we have multiple nodes, we shouldn't try setting up the native realm + // right away, or ES will complain as the cluster isn't ready. So we only + // set it up after the last node is started. + skipNativeRealmSetup: this.nodes.length > 1 && i < this.nodes.length - 1, + }); + }); } - await cluster.start(installPath, { - password: config.password, - esArgs, - esJavaOpts, - }); + await Promise.all(extractDirectoryPromises.map(async (extract) => await extract())); + for (const start of nodeStartPromises) { + await start(); + } } async stop() { - await cluster.stop(); + const nodeStopPromises = []; + for (let i = 0; i < this.nodes.length; i++) { + nodeStopPromises.push(async () => { + log.info(`[es] stopping node ${nodes[i].name}`); + return await this.nodes[i].stop(); + }); + } + await Promise.all(nodeStopPromises.map(async (stop) => await stop())); + log.info('[es] stopped'); } @@ -127,15 +283,63 @@ export function createTestEsCluster(options: TestClusterFactoryOptions) { */ getClient(): KibanaClient { return new Client({ - node: this.getUrl(), + node: this.getHostUrls()[0], }); } getUrl() { + if (this.nodes.length > 1) { + throw new Error( + '`getUrl()` can only be used with a single-node cluster. For multi-node clusters, use `getHostUrls()`.' + ); + } + const parts = esTestConfig.getUrlParts(); parts.port = port; return format(parts); } - })(); + + /** + * Returns a list of host URLs for the cluster. Intended for use + * when configuring Kibana's `elasticsearch.hosts` in a test. + * + * If the cluster has multiple nodes, each node URL will be included + * in this list. + */ + getHostUrls(): string[] { + return this.ports.map((p) => format({ ...esTestConfig.getUrlParts(), port: p })); + } + })() as EsTestCluster; +} + +/** + * Like `Object.assign`, but for arrays of `key=val` strings. Takes an arbitrary + * number of arrays, and allows values in subsequent args to override previous + * values for the same key. + * + * @example + * + * assignArgs(['foo=a', 'bar=b'], ['foo=c', 'baz=d']); // returns ['foo=c', 'bar=b', 'baz=d'] + */ +function assignArgs(...args: string[][]): string[] { + const toArgsObject = (argList: string[]) => { + const obj: Record = {}; + + argList.forEach((arg) => { + const [key, val] = arg.split('='); + obj[key] = val; + }); + + return obj; + }; + + return Object.entries( + args.reduce((acc, cur) => { + return { + ...acc, + ...toArgsObject(cur), + }; + }, {}) + ).map(([key, val]) => `${key}=${val}`); } diff --git a/packages/kbn-test/src/index.ts b/packages/kbn-test/src/index.ts index af100a33ea3a7..86cbc121703ec 100644 --- a/packages/kbn-test/src/index.ts +++ b/packages/kbn-test/src/index.ts @@ -25,7 +25,13 @@ export { runTests, startServers } from './functional_tests/tasks'; // @internal export { KIBANA_ROOT } from './functional_tests/lib/paths'; -export { esTestConfig, createTestEsCluster } from './es'; +export { + esTestConfig, + createTestEsCluster, + CreateTestEsClusterOptions, + EsTestCluster, + ICluster, +} from './es'; export { kbnTestConfig, kibanaServerTestUser, kibanaTestUser, adminTestUser } from './kbn'; diff --git a/packages/kbn-test/src/jest/setup/babel_polyfill.js b/packages/kbn-test/src/jest/setup/babel_polyfill.js index d112e4d4fcb39..7dda4cceec65c 100644 --- a/packages/kbn-test/src/jest/setup/babel_polyfill.js +++ b/packages/kbn-test/src/jest/setup/babel_polyfill.js @@ -9,4 +9,4 @@ // Note: In theory importing the polyfill should not be needed, as Babel should // include the necessary polyfills when using `@babel/preset-env`, but for some // reason it did not work. See https://github.com/elastic/kibana/issues/14506 -import '@kbn/optimizer/src/node/polyfill'; +import '@kbn/optimizer/target/node/polyfill'; diff --git a/packages/kbn-tinymath/grammar/grammar.peggy b/packages/kbn-tinymath/grammar/grammar.peggy index 1c6f8c3334c23..414bc2fa11cb7 100644 --- a/packages/kbn-tinymath/grammar/grammar.peggy +++ b/packages/kbn-tinymath/grammar/grammar.peggy @@ -43,7 +43,7 @@ Literal "literal" // Quoted variables are interpreted as strings // but unquoted variables are more restrictive Variable - = _ [\'] chars:(ValidChar / Space / [\"])* [\'] _ { + = _ '"' chars:("\\\"" { return "\""; } / [^"])* '"' _ { return { type: 'variable', value: chars.join(''), @@ -51,7 +51,7 @@ Variable text: text() }; } - / _ [\"] chars:(ValidChar / Space / [\'])* [\"] _ { + / _ "'" chars:("\\\'" { return "\'"; } / [^'])* "'" _ { return { type: 'variable', value: chars.join(''), diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js index bbc8503684fd4..9d87919c4f1ac 100644 --- a/packages/kbn-tinymath/test/library.test.js +++ b/packages/kbn-tinymath/test/library.test.js @@ -92,6 +92,7 @@ describe('Parser', () => { expect(parse('@foo0')).toEqual(variableEqual('@foo0')); expect(parse('.foo0')).toEqual(variableEqual('.foo0')); expect(parse('-foo0')).toEqual(variableEqual('-foo0')); + expect(() => parse(`foo😀\t')`)).toThrow('Failed to parse'); }); }); @@ -103,6 +104,7 @@ describe('Parser', () => { expect(parse('"foo bar fizz buzz"')).toEqual(variableEqual('foo bar fizz buzz')); expect(parse('"foo bar baby"')).toEqual(variableEqual('foo bar baby')); expect(parse(`"f'oo"`)).toEqual(variableEqual(`f'oo`)); + expect(parse(`"foo😀\t"`)).toEqual(variableEqual(`foo😀\t`)); }); it('strings with single quotes', () => { @@ -119,6 +121,7 @@ describe('Parser', () => { expect(parse("'foo bar '")).toEqual(variableEqual("foo bar ")); expect(parse("'0foo'")).toEqual(variableEqual("0foo")); expect(parse(`'f"oo'`)).toEqual(variableEqual(`f"oo`)); + expect(parse(`'foo😀\t'`)).toEqual(variableEqual(`foo😀\t`)); /* eslint-enable prettier/prettier */ }); diff --git a/packages/kbn-ui-framework/BUILD.bazel b/packages/kbn-ui-framework/BUILD.bazel new file mode 100644 index 0000000000000..f8cf5035bdc5f --- /dev/null +++ b/packages/kbn-ui-framework/BUILD.bazel @@ -0,0 +1,47 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-ui-framework" +PKG_REQUIRE_NAME = "@kbn/ui-framework" + +SOURCE_FILES = glob([ + "dist/**/*", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-ui-shared-deps/src/entry.js b/packages/kbn-ui-shared-deps/src/entry.js index b8d21a473c65f..0e91c45ae6392 100644 --- a/packages/kbn-ui-shared-deps/src/entry.js +++ b/packages/kbn-ui-shared-deps/src/entry.js @@ -40,6 +40,7 @@ export const ElasticEui = require('@elastic/eui'); export const ElasticEuiLibServices = require('@elastic/eui/lib/services'); export const ElasticEuiLibServicesFormat = require('@elastic/eui/lib/services/format'); export const ElasticEuiChartsTheme = require('@elastic/eui/dist/eui_charts_theme'); +export const ReactBeautifulDnD = require('react-beautiful-dnd'); export const Theme = require('./theme.ts'); export const Lodash = require('lodash'); export const LodashFp = require('lodash/fp'); diff --git a/packages/kbn-ui-shared-deps/src/index.js b/packages/kbn-ui-shared-deps/src/index.js index c5853dc091875..36c2e6b02879e 100644 --- a/packages/kbn-ui-shared-deps/src/index.js +++ b/packages/kbn-ui-shared-deps/src/index.js @@ -85,6 +85,8 @@ exports.externals = { '@elastic/eui/dist/eui_charts_theme': '__kbnSharedDeps__.ElasticEuiChartsTheme', '@elastic/eui/dist/eui_theme_light.json': '__kbnSharedDeps__.Theme.euiLightVars', '@elastic/eui/dist/eui_theme_dark.json': '__kbnSharedDeps__.Theme.euiDarkVars', + // transient dep of eui + 'react-beautiful-dnd': '__kbnSharedDeps__.ReactBeautifulDnD', lodash: '__kbnSharedDeps__.Lodash', 'lodash/fp': '__kbnSharedDeps__.LodashFp', fflate: '__kbnSharedDeps__.Fflate', diff --git a/rfcs/images/0019_lifecycle_preboot.png b/rfcs/images/0019_lifecycle_preboot.png new file mode 100644 index 0000000000000..73eeb901eb33f Binary files /dev/null and b/rfcs/images/0019_lifecycle_preboot.png differ diff --git a/rfcs/images/20_clustering/cluster_mode.png b/rfcs/images/20_clustering/cluster_mode.png new file mode 100644 index 0000000000000..dad24c42f7108 Binary files /dev/null and b/rfcs/images/20_clustering/cluster_mode.png differ diff --git a/rfcs/images/20_clustering/no_cluster_mode.png b/rfcs/images/20_clustering/no_cluster_mode.png new file mode 100644 index 0000000000000..f2f12428077be Binary files /dev/null and b/rfcs/images/20_clustering/no_cluster_mode.png differ diff --git a/rfcs/images/20_clustering/perf_4_workers.png b/rfcs/images/20_clustering/perf_4_workers.png new file mode 100644 index 0000000000000..114a3d9528388 Binary files /dev/null and b/rfcs/images/20_clustering/perf_4_workers.png differ diff --git a/rfcs/images/20_clustering/perf_clustering_2_worker.png b/rfcs/images/20_clustering/perf_clustering_2_worker.png new file mode 100644 index 0000000000000..a767de58eba69 Binary files /dev/null and b/rfcs/images/20_clustering/perf_clustering_2_worker.png differ diff --git a/rfcs/images/20_clustering/perf_no_clustering.png b/rfcs/images/20_clustering/perf_no_clustering.png new file mode 100644 index 0000000000000..f2915c4badcf0 Binary files /dev/null and b/rfcs/images/20_clustering/perf_no_clustering.png differ diff --git a/rfcs/text/0019_lifecycle_preboot.md b/rfcs/text/0019_lifecycle_preboot.md new file mode 100644 index 0000000000000..d1448ad4ce15a --- /dev/null +++ b/rfcs/text/0019_lifecycle_preboot.md @@ -0,0 +1,261 @@ +- Start Date: 2020-06-04 +- RFC PR: (leave this empty) +- Kibana Issue: https://github.com/elastic/kibana/issues/89287 + +--- +- [1. Summary](#1-summary) +- [2. Motivation](#2-motivation) +- [3. Detailed design](#3-detailed-design) + - [3.1 Core client-side changes](#31-core-client-side-changes) + - [3.2 Core server-side changes](#32-core-server-side-changes) + - [3.2.1 Plugins service](#321-plugins-service) + - [3.2.2 HTTP service](#322-http-service) + - [3.2.3 Elasticsearch service](#323-elasticsearch-service) + - [3.2.4 UI Settings service](#324-ui-settings-service) + - [3.2.5 Rendering service](#325-rendering-service) + - [3.2.6 I18n service](#326-i18n-service) + - [3.2.7 Environment service](#327-environment-service) + - [3.2.8 Core app service](#328-core-app-service) + - [3.2.9 Preboot service](#329-preboot-service) + - [3.2.10 Bootstrap](#3210-bootstrap) +- [4. Drawbacks](#4-drawbacks) +- [5. Alternatives](#5-alternatives) +- [6. Adoption strategy](#6-adoption-strategy) +- [7. How we teach this](#7-how-we-teach-this) +- [8. Unresolved questions](#8-unresolved-questions) + - [8.1 Lifecycle stage name](#81-lifecycle-stage-name) + - [8.2 Development mode and basepath proxy](#82-development-mode-and-basepath-proxy) +- [9. Resolved questions](#9-resolved-questions) + - [9.1 Core client-side changes](#91-core-client-side-changes) + +# 1. Summary + +The `preboot` (see [unresolved question 1](#81-lifecycle-stage-name)) is the Kibana initial lifecycle stage at which it only initializes a bare minimum of the core services and a limited set of special-purpose plugins. It's assumed that Kibana can change and reload its own configuration at this stage and may require administrator involvement before it can proceed to the `setup` and `start` stages. + +# 2. Motivation + +The `preboot` lifecycle stage is a prerequisite for the Kibana interactive setup mode. This is the mode Kibana enters to on the first launch if it detects that user hasn't explicitly configured their own connection to Elasticsearch. In this mode, Kibana will present an interface to the user that would allow them to provide Elasticsearch connection information and potentially any other configuration information. Once the information is verified, Kibana will write it to the disk and allow the rest of Kibana to start. + +The interactive setup mode will be provided through a dedicated `userSetup` plugin that will be initialized at the `preboot` stage. + +# 3. Detailed design + +The central part of the `preboot` stage is a dedicated HTTP server instance formerly known as `Not Ready` server. Kibana starts this server at the `preboot` stage and shuts it down as soon as the main HTTP server is ready to start, as illustrated at the following diagram: + +![Preboot plugins lifetime](../images/0019_lifecycle_preboot.png) + +Currently, preboot HTTP server only exposes a status endpoint and renders a static `Kibana server is not ready yet` string whenever users try to access Kibana before it's completely initialized. The changes proposed in this RFC should allow special-purpose plugins to define custom HTTP endpoints, and serve interactive client-side applications on this server, and hence make Kibana interactive setup mode possible. + +## 3.1 Core client-side changes + +The RFC aims to limit the changes to only those that are absolutely required and doesn't assume any modifications in the client-side part of the Kibana Core at the moment. This may introduce a certain level of inconsistency in the client-side codebase, but we consider it insignificant. See [resolved question 1](#91-core-client-side-changes) for more details. + +## 3.2 Core server-side changes + +We'll update only several Core server-side services to support the new `preboot` lifecycle stage and preboot plugins. + +Once none of the `preboot` plugins holds the `setup` anymore, Kibana might need to reload the configuration before it can finally proceed to `setup`. This doesn't require any special care from the existing plugin developers since Kibana would instantiate plugins only after it reloads the config. We'll also make sure that neither of the Core services relies on the stale configuration it may have acquired during the `preboot` stage. + +### 3.2.1 Plugins service + +First of all, we'll introduce a new type of special-purpose plugins: preboot plugins, in contrast to standard plugins. Kibana will initialize preboot plugins at the `preboot` stage, before even instantiating standard plugins. + +Preboot plugins have only `setup` and `stop` methods, and can only depend on other preboot plugins. Standard plugins cannot depend on the preboot plugins since Kibana will stop them before starting the standard plugins: + +```ts +export interface PrebootPlugin { + setup(core: CorePrebootSetup, plugins: TPluginsSetup): TSetup; + stop?(): void; +} +``` + +To differentiate preboot and standard plugins we'll introduce a new _optional_ `type` property in the plugin manifest. The property can have only two possible values: `preboot` for `preboot` plugins and `standard` for the standard ones. If `type` is omitted, the `standard` value will be assumed. + +```json5 +// NOTE(azasypkin): all other existing properties have been omitted for brevity. +{ + "type": "preboot", // 'preboot' | 'standard' | undefined +} +``` + +The Plugins service will split plugins into two separate groups during discovery to use them separately at the `preboot`, `setup`, and `start` stages. The Core contract that preboot plugins will receive during their `setup` will be different from the one standard plugins receive, and will only include the functionality that is currently required for the interactive setup mode. We'll discuss this functionality in details in the following sections: + +```ts +export interface CorePrebootSetup { + elasticsearch: ElasticsearchServicePrebootSetup; + http: HttpServicePrebootSetup; + preboot: PrebootServiceSetup; +} +``` + +### 3.2.2 HTTP service + +We'll change HTTP service to initialize and start preboot HTTP server (formerly known as `Not Ready` server) in the new `preboot` method instead of `setup`. The returned `InternalHttpServicePrebootSetup` contract will presumably be very similar to the existing `InternalHttpServiceSetup` contract, but will only include APIs we currently need to support interactive setup mode: + +```ts +// NOTE(azasypkin): some existing properties have been omitted for brevity. +export interface InternalHttpServicePrebootSetup + extends Pick { + server: HttpServerSetup['server']; + externalUrl: ExternalUrlConfig; + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} +``` + +The only part of this contract that will be available to the preboot plugins via `CorePrebootSetup` is the API to register HTTP routes on the already running preboot HTTP server: + +```ts +export interface HttpServicePrebootSetup { + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} +``` + +The Core HTTP context available to handlers of the routes registered on the preboot HTTP server will only expose the `uiSettings` service. As explained in the [UI Settings service section](#324-ui-settings-service), this service will only give access to the **default Core** UI settings and their overrides set through Kibana configuration, if any. +```ts +// NOTE(azasypkin): the fact that the client is lazily initialized has been omitted for brevity. +export interface PrebootCoreRouteHandlerContext { + readonly uiSettings: { client: IUiSettingsClient }; +} +``` + +The authentication and authorization components are not available at the `preboot` stage, and hence all preboot HTTP server routes can be freely accessed by anyone with access to the network Kibana is exposed to. + +Just as today, Kibana will shut the preboot HTTP server down as soon as it's ready to start the main HTTP server. + +### 3.2.3 Elasticsearch service + +As mentioned in the [Motivation section](#2-motivation), the main goal of the interactive setup mode is to give the user a hassle-free way to configure Kibana connection to an Elasticsearch cluster. That means that users might provide certain connection information, and Kibana preboot plugins should be able to construct a new Elasticsearch client using this information to verify it and potentially call Elasticsearch APIs. + +To support this use case we'll add a new `preboot` method to the Elasticsearch service that will return the following contract, and make it available to the preboot plugins via `CorePrebootSetup`: + +```ts +export interface ElasticsearchServicePrebootSetup { + readonly createClient: ( + type: string, + clientConfig?: Partial + ) => ICustomClusterClient; +} +``` + +The Elasticsearch clients created with `createClient` rely on the default Kibana Elasticsearch configuration and any configuration overrides specified by the consumer. + +__NOTE:__ We may need to expose a full or portion of Elasticsearch config to the preboot plugins for them to check if the user has already configured Elasticsearch connection. There are other ways to check that without direct access to the configuration though. + +### 3.2.4 UI Settings service + +We'll introduce a new `preboot` method in the UI Settings service that will produce a UI Settings client instance. Since during the `preboot` stage Kibana can access neither user information nor Saved Objects, this client will only give access to the **default Core** UI settings and their overrides set through Kibana configuration, if any: + +```ts +export interface InternalUiSettingsServicePrebootSetup { + defaultsClient(): IUiSettingsClient; +} +``` + +UI Settings service isn't strictly necessary during the `preboot` stage, but many Kibana Core components rely on it explicitly and implicitly, which justifies this simple change. + +### 3.2.5 Rendering service + +We'll introduce a new `preboot` method in the Rendering service that will register Kibana main UI bootstrap template route on the preboot HTTP server as it does for the main HTTP server today. The main difference is that bootstrap UI will only reference bundles of the preboot plugins and will rely on the default UI settings. + +### 3.2.6 I18n service + +We'll introduce a new `preboot` method in the I18n service to only include translations for the Core itself and preboot plugins in the translations bundle loaded with the preboot UI bootstrap template. This would potentially allow us to switch locale during interactive setup mode if there is such a need in the future. + +### 3.2.7 Environment service + +There are no changes required in the Environment service itself, but we'll expose one additional property from its `setup` contract to the plugins: the paths to the known configuration files. The interactive setup mode should be able to figure out to which configuration file Kibana should save any changes users might need to make. + +### 3.2.8 Core app service + +We'll introduce a new `preboot` method in the Core app service to register routes on the preboot HTTP server necessary for the rendering of the Kibana preboot applications. Most of the routes will be the same as for the main HTTP server, but there are three notable exceptions: + +1. JS bundles routes will only include those exposed by the preboot plugins + +2. Default route for the preboot HTTP server will be hardcoded to the root path (`/`) since we cannot rely on the default value of the `defaultRoute` UI setting (`/app/home`) + +3. Main application route (`/app/{id}/{any*}`) will be replaced with the catch-all route (`/{path*}`). The reason is that if the user tries to access Kibana with a legit standard application URL (e.g. `/app/discover/?parameters`) while Kibana is still at the `preboot` stage, they will end up with `Application is not found` error. Instead, with the catch-all route, Kibana will capture the original URL in the `next` query string parameter and redirect the user to the root (e.g. `/?next=%2Fapp%2Fdiscover%2F%3Fparameters`). This will allow us to automatically redirect the user back to the original URL as soon as Kibana is ready. The main drawback and limitation of this approach are that there can be only one root-level preboot application. We can lift this limitation in the future if we have to though, for example, to support post-preboot Saved Objects migration UI or something similar. + +Serving a proper Kibana application on the root route of the preboot HTTP server implies that we'll also have a chance to replace the static `Kibana server is not ready yet` string with a more helpful and user-friendly application. Such application may potentially display a certain set of Kibana status information. + +### 3.2.9 Preboot service + +To support interactive applications at the `preboot` stage we should allow preboot plugins to pause Kibana startup sequence. This functionality will be exposed by the new Preboot service, and will be available to the preboot plugins via `CorePrebootSetup`. Preboot plugins will be able to provide a promise to hold `setup` and/or `start` for as long as needed, and also let Kibana know if it has to reload configuration before it enters the `setup` stage. + +```ts +export interface PrebootServiceSetup { + readonly isSetupOnHold: () => boolean; + readonly holdSetupUntilResolved: ( + reason: string, + promise: Promise<{ shouldReloadConfig: boolean } | void> + ) => void; + readonly isStartOnHold: () => boolean; + readonly holdStartUntilResolved: ( + reason: string, + promise: Promise + ) => void +} +``` + +Preboot service will provide a pair of helper `isSetupOnHold` and `isStartOnHold` methods that would allow consumers to check if `setup` or `start` are on hold before they are blocked on waiting. + +Internal Preboot service contract will also expose `waitUntilCanSetup` and `waitUntilCanStart` methods that bootstrap process can use to know when it can proceed to `setup` and `start` stages. If any of these methods returns a `Promise` that is rejected, Kibana will shut down. + +```ts +// NOTE(azasypkin): some existing properties have been omitted for brevity. +export interface InternalPrebootServiceSetup { + readonly waitUntilCanSetup: () => Promise<{ shouldReloadConfig: boolean } | void>; + readonly waitUntilCanStart: () => Promise; +} +``` + +### 3.2.10 Bootstrap + +We'll update Kibana bootstrap sequence to include `preboot` stage and to conditionally reload configuration before proceeding to `setup` and `start` stages: + +```ts +// NOTE(azasypkin): some functionality and checks have been omitted for brevity. +const { preboot } = await root.preboot(); + +const { shouldReloadConfig } = await preboot.waitUntilCanSetup(); +if (shouldReloadConfig) { + await reloadConfiguration('pre-boot request'); +} +await root.setup(); + +await preboot.waitUntilCanStart(); +await root.start(); +``` + +It's not yet clear if we need to adjust the base path proxy to account for this new lifecycle stage (see [unresolved question 2](#82-development-mode-and-basepath-proxy)). + +# 4. Drawbacks + +The main drawback is that proposed changes affect quite a few Kibana Core services that may impose a risk of breaking something in the critical parts of Kibana. + +# 5. Alternatives + +The most viable alternative to support interactive setup mode for Kibana was a standalone application that would be completely separated from Kibana. We ruled out this option since we won't be able to leverage existing and battle-tested Core services, UI components, and development tools. This would make the long-term maintenance burden unreasonably high. + +# 6. Adoption strategy + +The new `preboot` stage doesn't need an adoption strategy since it's intended for internal platform use only. + +# 7. How we teach this + +The new `preboot` stage shouldn't need much knowledge sharing since it's intended for internal platform use only and doesn't affect the standard plugins. All new services, methods, and contracts will be sufficiently documented in the code. + +# 8. Unresolved questions + +## 8.1 Lifecycle stage name + +Is `preboot` the right name for this new lifecycle stage? Do we have a better alternative? + +## 8.2 Development mode and basepath proxy + +Currently, the base path proxy blocks any requests to Kibana until it receives `SERVER_LISTENING` message. Kibana's main process sends this message only after `start`, but we should change that to support interactive preboot applications. It's not yet clear how big the impact of this change will be. + +# 9. Resolved questions + +## 9.1 Core client-side changes + +The server-side part of the `preboot` plugins will follow a new `PrebootPlugin` interface that doesn't have a `start` method, but the client-side part will stay the same as for standard plugins. This significantly simplifies implementation and doesn't introduce any known technical issues, but, unfortunately, brings some inconsistency to the codebase. We agreed that it's tolerable assuming we define a dedicated client-side `PrebootPlugin` interface that would hide from `CoreStart` all services that are unavailable to the preboot plugins (e.g., Saved Objects service). \ No newline at end of file diff --git a/rfcs/text/0020_nodejs_clustering.md b/rfcs/text/0020_nodejs_clustering.md new file mode 100644 index 0000000000000..9ee5d764c8de1 --- /dev/null +++ b/rfcs/text/0020_nodejs_clustering.md @@ -0,0 +1,729 @@ +- Start Date: 2021-03-09 +- RFC PR: https://github.com/elastic/kibana/pull/94057 +- Kibana Issue: https://github.com/elastic/kibana/issues/68626 +- POC PR: https://github.com/elastic/kibana/pull/93380 + +--- + +- [1. Summary](#1-summary) +- [2. Motivation](#2-motivation) +- [3. Architecture](#3-architecture) +- [4. Testing](#4-testing) +- [5. Detailed design](#5-detailed-design) +- [6. Technical impact](#6-technical-impact) + - [6.1 Technical impact on Core](#6.1-technical-impact-on-core) + - [6.2 Technical impact on Plugins](#6.2-technical-impact-on-plugins) + - [6.3 Summary of breaking changes](#6.3-summary-of-breaking-changes) +- [7. Drawbacks](#7-drawbacks) +- [8. Alternatives](#8-alternatives) +- [9. Adoption strategy](#9-adoption-strategy) +- [10. How we teach this](#10-how-we-teach-this) +- [11. Unresolved questions](#11-unresolved-questions) +- [12. Resolved questions](#12-resolved-questions) + +# 1. Summary + +This RFC proposes a new core service which leverages the [Node.js cluster API](https://nodejs.org/api/cluster.html) +to support multi-process Kibana instances. + +# 2. Motivation + +The Kibana server currently uses a single Node process to serve HTTP traffic. +This is a byproduct of the single-threaded nature of Node's event loop. + +As a consequence, Kibana cannot take advantage of multi-core hardware: If you run Kibana on an +8-core machine, it will only utilize one of those cores. This makes it expensive to scale out +Kibana, as server hardware will typically have multiple cores, so you end up paying for power +you never use. Since Kibana is generally more CPU-intensive than memory-intensive, it would be +advantageous to use all available cores to maximize the performance we can get out of a single +machine. + +Another benefit of this approach would be improving Kibana's overall performance for most users +without requiring an operator to scale out the server, as it would allow the server to handle +more http requests at once, making it less likely that a single bad request could delay the +event loop and impact subsequent requests. + +The introduction of a clustering mode would allow spawning multiple Kibana processes ('workers') +from a single Kibana instance. (See [Alternatives](#8-alternatives) to learn more about the +difference between clustering and worker pools). You can think of these processes as individual +instances of the Kibana server which listen on the same port on the same machine, and serve +incoming traffic in a round-robin fashion. + +Our intent is to eventually make clustering the default behavior in Kibana, taking advantage of +all available CPUs out of the box. However, this should still be an optional way to run Kibana +since users might have use cases for single-process instances (for example, users running Kibana +inside Docker containers might choose to rather use their container orchestration to run a +container per host CPU with a single Kibana process per container). + +# 3. Architecture + +In 'classic' mode, the Kibana server is started in the main Node.js process. + +![image](../images/20_clustering/no_cluster_mode.png) + +In clustering mode, the main Node.js process would only start the coordinator, which would then +fork workers using Node's `cluster` API. Node's underlying socket implementation allows multiple +processes to listen to the same ports, effectively performing http traffic balancing between the +workers for us. + +![image](../images/20_clustering/cluster_mode.png) + +The coordinator's primary responsibility is to orchestrate the workers. It would not be a 'super' +worker handling both the job of a worker while being in charge of managing the other workers. + +In addition, the coordinator would be responsible for some specific activities that need to be +handled in a centralized manner: +- collecting logs from each of the workers & writing them to a single file or stdout +- gathering basic status information from each worker for use in the `/status` and `/stats` APIs + +Over time, it is possible that the role of the coordinator would expand to serve more purposes, +especially if we start implementing custom routing logic to run different services on specialized +processes. + +# 4. Testing + +Thorough performance testing is critical in evaluating the success of this plan. The results +below reflect some initial testing that was performed against an experimental +[proof-of-concept](https://github.com/elastic/kibana/pull/93380). Should we move forward with this +RFC, one of the first tasks will be to update the POC and build out a more detailed test plan that +covers all of the scenarios we are concerned with. + +## 4.1 Local testing + +These tests were performed against a local development machine, with an 8-core CPU(2.4 GHz 8-Core +Intel Core i9 - 32 GB 2400 MHz DDR4), using the default configuration of the `kibana-load-testing` tool. + +### 4.1.1 Raw results + +#### Non-clustered mode + +![image](../images/20_clustering/perf_no_clustering.png) + +#### Clustered mode, 2 workers + +![image](../images/20_clustering/perf_clustering_2_worker.png) + +#### Clustered mode, 4 workers + +![image](../images/20_clustering/perf_4_workers.png) + +### 4.1.2 Analysis + +- Between non-clustered and 2-worker cluster mode, we observe a 20/25% gain in the 50th percentile response time. + Gain for the 75th and 95th are between 10% and 40% +- Between 2-worker and 4-workers cluster mode, the gain on 50th is negligible, but the 75th and the 95th are + significantly better on the 4-workers results, sometimes up to 100% gain (factor 2 ratio) + +Overall, switching to 2 workers comes with the most significant improvement in the 50th pct, +and increasing further to 4 workers decreases even more significantly the highest percentiles. +Even if increasing the number of workers doesn’t just linearly increase the performances +(which totally make sense, most of our requests response time is caused by awaiting ES response), +the improvements of the clustering mode on performance under heavy load are far from negligible. + +## 4.2 Testing against cloud + +There is currently no easy way to test the performance improvements this could provide on Cloud, as we can't +deploy custom builds or branches on Cloud at the moment. + +On Cloud, Kibana is running in a containerised environment using CPU CFS quota and CPU shares. + +If we want to investigate the potential perf improvement on Cloud further, our only option would be to setup a +similar-ish environment locally (which wasn't done during the initial investigation). + +# 5. Detailed design + +## 5.1 Enabling clustering mode + +Enabling clustering mode will be done using the `node.enabled` configuration property. + +If clustering is enabled by default, then no configuration would be required by users, and +Kibana would automatically use all available cores. However, more detailed configuration +would be available for users with more advanced use cases: +```yaml +node: + enabled: true # enabled by default + + coordinator: + max_old_space_size: 1gb # optional, allows to configure memory limit for coordinator only + + # Basic config for multiple workers with the same options + workers: # when count is provided, all workers share the same config + count: 2 # worker names (for logging) are generated: `worker-1`, `worker-2` + max_old_space_size: 1gb # optional, allows to configure memory limits per-worker + + # Alternative advanced config, allowing for worker "types" to be configured + workers: + foo: # the key here would be used as the worker name + count: 2 + max_old_space_size: 1gb + bar: + count: 1 + max_old_space_size: 512mb +``` + +This per-worker design would give us the flexibility to eventually provide more fine-grained configuration, +like dedicated workers for http requests or background jobs. + +## 5.2 Cross-worker communication + +For some of our changes (such as the `/status` API, see below), we will need some kind of cross-worker +communication. This will need to pass through the coordinator, which will also serve as an 'event bus', +or IPC forwarder. + +This IPC API will be exposed from the node service: + +```ts +export interface NodeServiceSetup { + // [...] + broadcast: (type: string, payload?: WorkerMessagePayload, options?: BroadcastOptions) => void; + addMessageHandler: (type: string, handler: MessageHandler) => MessageHandlerUnsubscribeFn; +} +``` + +To preserve isolation and to avoid creating an implicit cross-plugin API, handlers registered from a +given plugin will only be invoked for messages sent by the same plugin. + +Notes: +- To reduce clustered and non-clustered mode divergence, in non-clustered mode, these APIs would just be no-ops. + It will avoid forcing (most) code to check which mode Kibana is running before calling them. + - In the case where `sendToSelf` is true, we would still attempt to broadcast the message. +- We could eventually use an Observable pattern instead of a handler pattern to subscribe to messages. + +## 5.3 Executing code on a single worker + +In some scenarios, we would like to have parts of the code executed only from a single process. + +Saved object migrations would be a good example: +we don't need to have each worker try to perform the migration, and we'd prefer to have one performing/trying +the migration, and the others waiting for it. Due to the architecture, we can't have the coordinator perform +such single-process jobs, as it doesn't actually run a Kibana server. + +There are various ways to address such use-cases. What seems to be the best compromise right now would be the +concept of 'main worker'. The coordinator would arbitrarily elect a worker as the 'main' one at startup. The +node service would then expose an API to let workers identify themselves as main or not. + +```ts +export interface NodeServiceSetup { + // [...] + isMainWorker: () => boolean; +} +``` + +Notes: +- In non-clustered mode, `isMainWorker` would always return true, to reduce the divergence between clustered and + non-clustered modes. + +## 5.4 The node service API + +We propose adding a new node service to Core, which will be responsible for adding the necessary cluster APIs, +and handling interaction with Node's `cluster` API. This service would be accessible via Core's setup and start contracts +(`coreSetup.node` and `coreStart.node`). + +At the moment, no need to extend Core's request handler context with node related APIs has been identified. + +The initial contract interface would look like this: + +```ts +type WorkerMessagePayload = Serializable; + +interface BroadcastOptions { + /** + * If true, will also send the message to the worker that sent it. + * Defaults to false. + */ + sendToSelf?: boolean; + /** + * If true, the message will also be sent to subscribers subscribing after the message was effectively sent. + * Defaults to false. + */ + persist?: boolean; +} + +export interface NodeServiceSetup { + /** + * Return true if clustering mode is enabled, false otherwise + */ + isEnabled: () => boolean; + /** + * Return the current worker's id. In non-clustered mode, will return `1` + */ + getWorkerId: () => number; + /** + * Broadcast a message to other workers. + * In non-clustered mode, this is a no-op. + */ + broadcast: (type: string, payload?: WorkerMessagePayload, options?: BroadcastOptions) => void; + /** + * Registers a handler for given `type` of IPC messages + * In non-clustered mode, this is a no-op that returns a no-op unsubscription callback. + */ + addMessageHandler: (type: string, handler: MessageHandler) => MessageHandlerUnsubscribeFn; + /** + * Returns true if the current worker has been elected as the main one. + * In non-clustered mode, will always return true + */ + isMainWorker: () => boolean; +} +``` + +### 5.4.1 Example: Saved Object Migrations + +To take the example of SO migration, the `KibanaMigrator.runMigrations` implementation could change to +(naive implementation, the function is supposed to return a promise here, did not include that for simplicity): + +```ts +runMigration() { + if (node.isMainWorker()) { + this.runMigrationsInternal().then((result) => { + applyMigrationState(result); + // persist: true will send message even if subscriber subscribes after the message was actually sent + node.broadcast('migration-complete', { payload: result }, { persist: true }); + }) + } else { + const unsubscribe = node.addMessageHandler('migration-complete', ({ payload: result }) => { + applyMigrationState(result); + unsubscribe(); + }); + } +} +``` + +Notes: + - To be sure that we do not encounter a race condition with the event subscribing / sending (workers subscribing after + the main worker actually sent the `migration-complete` event and then waiting indefinitely), we are using the `persist` + option of the `broadcast` API. We felt this was a better approach than the alternative of having shared state among workers. + +## 5.5 Sharing state between workers + +This is not identified as necessary at the moment, and IPC broadcast should be sufficient, hopefully. We prefer to avoid +the added complexity and risk of implicit dependencies if possible. + +If we do eventually need shared state, we would probably have to use syscall libraries to share buffers such as +[mmap-io](https://www.npmjs.com/package/mmap-io), and expose a higher level API for that from the `node` service. More +research would be required if this proved to be a necessity. + +# 6. Technical impact + +This section attempts to be an exhaustive inventory of the changes that would be required to support clustering mode. + +## 6.1 Technical impact on Core + +### 6.1.1 Handling multi-process logs + +This is an example of log output in a 2 workers cluster, coming from the POC: + +``` +[2021-03-02T10:23:41.834+01:00][INFO ][plugins-service] Plugin initialization disabled. +[2021-03-02T10:23:41.840+01:00][INFO ][plugins-service] Plugin initialization disabled. +[2021-03-02T10:23:41.900+01:00][WARN ][savedobjects-service] Skipping Saved Object migrations on startup. Note: Individual documents will still be migrated when read or written. +[2021-03-02T10:23:41.903+01:00][WARN ][savedobjects-service] Skipping Saved Object migrations on startup. Note: Individual documents will still be migrated when read or written. +``` + +The workers logs are interleaved, and, most importantly, there is no way to see which process each log entry is coming from. +We will need to address that. + +#### Options we considered: + +1. Having a distinct logging configuration (with separate log files) for each worker +2. Centralizing log collection in the coordinator and writing all logs to a single file (or stdout) + +#### Our recommended approach: + +Overall we recommend keeping a single log file (option 2), and centralizing the logging system in the coordinator, +with each worker sending the coordinator log messages via IPC. While this is a more complex implementation in terms +of our logging system, it solves several problems: +- Preserves backwards compatibility. +- Avoids the issue of interleaved log messages that could occur with multiple processes writing to the same file or stdout. +- Provides a solution for the rolling-file appender (see below), as the coordinator would handle rolling all log files +- The changes to BaseLogger could potentially have the added benefit of paving the way for our future logging MDC. + +We could add the process name information to the log messages, and add a new conversion to be able to display it with +the pattern layout, such as `%worker` for example. + +The default pattern could evolve to (ideally, only when clustering is enabled): +``` +[%date][%level][%worker][%logger] %message +``` + +The logging output would then look like: +``` +[2021-03-02T10:23:41.834+01:00][INFO ][worker-1][plugins-service] Plugin initialization disabled. +[2021-03-02T10:23:41.840+01:00][INFO ][worker-2][plugins-service] Plugin initialization disabled. +``` + +Notes: +- The coordinator will probably need to output logs too. `%worker` would be interpolated to `coordinator` + for the coordinator process. +- Even if we add the `%worker` pattern, we could still consider letting users configure per-worker log +files as a future enhancement. + +### 6.1.2 The rolling-file appender + +The rolling process of the `rolling-file` appender is going to be problematic in clustered mode, as it will cause +concurrency issues during the rolling. We need to find a way to have this rolling stage clustered-proof. + +#### Options we considered: + +1. have the rolling file appenders coordinate themselves when rolling + +By using a broadcast message based mutex mechanism, the appenders could acquire a ‘lock’ to roll a specific file, and +notify other workers when the rolling is complete (quite similar to what we want to do with SO migration for example). + +An alternative to this option would be to only have the main worker handle the rolling logic. We will lose control +on the exact size the file is when rolling, as we would need to wait until the main worker receives a log message +for the rolling appender before the rolling is effectively performed. The upside would be that it reduces the inter-workers +communication to a notification from the main worker to the others once the rolling is done for them to reopen their +file handler. + +2. have the coordinator process perform the rolling + +Another option would be to have the coordinator perform the rotation instead. When a rolling is required, the appender +would send a message to the coordinator, which would perform the rolling and notify the workers once the operation is complete. + +Note that this option is even more complicated than the previous one, as it forces to move the rolling implementation +outside of the appender, without any significant upsides identified. + +3. centralize the logging system in the coordinator + +We could go further, and change the way the logging system works in clustering mode by having the coordinator centralize +the logging system. The worker’s logger implementation would just send messages to the coordinator. If this may be a +correct design, the main downside is that the logging implementation would be totally different in cluster and +non cluster mode, and seems to be way more work that the other options. + +#### Our recommended approach: +Even though it's more complex, we feel that centralizing the logging system in the coordinator is the right move here, +as it will also solve for how to enable the coordinator to log its own messages. + +### 6.1.3 The status API + +In clustering mode, the workers will all have an individual status. One could have a connectivity issue with ES +while the other ones are green. Hitting the `/status` endpoint will reach a random (and different each time) worker, +meaning that it would not be possible to know the status of the cluster as a whole. + +We will need to add some centralized status state in the coordinator. Also, as the `/status` endpoint cannot be served +from the coordinator, we will also need to have the workers retrieve the global status from the coordinator to serve +the status endpoint. + +Ultimately, we'd need to make the following updates to the `/status` API, neither of which +is a breaking change: +1. The response will return the highest-severity status level for each plugin, which will be +determined by looking at the shared global status stored in the coordinator. +2. We will introduce an extension to the existing `/status` response to allow inspecting +per-worker statuses. + +### 6.1.4 The stats API & metrics service + +The `/stats` endpoint is somewhat problematic in that it contains a handful of `process` metrics +which will differ from worker-to-worker: +```json +{ + // ... + "process": { + "memory": { + "heap": { + "total_bytes": 533581824, + "used_bytes": 296297424, + "size_limit": 4345298944 + }, + "resident_set_size_bytes": 563625984 + }, + "pid": 52646, + "event_loop_delay": 0.22967800498008728, + "uptime_ms": 1706021.930404 + }, + // ... +} +``` + +As each request could be routed to a different worker, different results may come back each time. + +This endpoint, registered from the `usage_collection` plugin, is getting these stats from Core's +`metrics` service (`getOpsMetrics$`), which is also used in the `monitoring` plugin for stats +collection. + +Ultimately we will extend the API to provide per-worker stats, but the question remains what we +should do with the existing `process` stats. + +#### Options we considered: +1. Deprecate them? (breaking change) +2. Accept a situation where they may be round-robined to different workers? (probably no) +3. Try to consolidate them somehow? (can't think of a good way to do this) +4. Always return stats for one process, e.g. main or coordinator? (doesn't give us the full picture) + +#### Our recommended approach: +We agreed that we would go with (3) and have each worker report metrics to the coordinator for +sharing, with the metrics aggregated as follows: +```json +{ + // ... + "process": { + "memory": { + "heap": { + "total_bytes": 533581824, // sum of coordinator + workers + "used_bytes": 296297424, // sum of coordinator + workers + "size_limit": 4345298944 // sum of coordinator + workers + }, + "resident_set_size_bytes": 563625984 // sum of coordinator + workers + }, + "pid": 52646, // pid of the coordinator + "event_loop_delay": 0.22967800498008728, // max of coordinator + workers + "uptime_ms": 1706021.930404 // uptime of the coordinator + }, + // ... +} +``` + +This has its downsides (`size_limit` in particular could be confusing), but otherwise generally makes sense: +- sum of available/used heap & node rss is straightforward +- `event_loop_delay` max makes sense, as we are mostly only interested in that number if it is high anyway +- `pid` and `uptime_in_millis` from the coordinator make sense, especially as long as we are killing +all workers any time one of them dies. In the future if we respawn workers that die, this could be +misleading, but hopefully by then we can deprecate this and move Metricbeat to using the per-worker +stats. + +### 6.1.5 PID file + +Without changes, each worker is going to try to write and read the same PID file. Also, this breaks the whole pid file +usage, as the PID stored in the file will be a arbitrary worker’s PID, instead of the coordinator (main process) PID. + +In clustering mode, we will need to have to coordinator handle the PID file logic, and to disable pid file handling +in the worker's environment service. + +### 6.1.6 Saved Objects migration + +In the current state, all workers are going to try to perform the migration. Ideally, we would have only one process +perform the migration, and the other ones just wait for a ready signal. We can’t easily have the coordinator do it, +so we would probably have to leverage the ‘main worker’ concept here. + +The SO migration v2 is supposed to be resilient to concurrent attempts though, as we already support multi-instances +Kibana, so this can probably be considered an improvement. + +### 6.1.7 Memory consumption + +In clustered mode, node options such as `max-old-space-size` will be used by all processes. + +The `kibana` startup script will read this setting out of the CLI or `config/node.options` and set a NODE_OPTIONS environment +variable, which will be passed to any workers, possibly leading to unexpected behavior. + +e.g. using `--max-old-space-size=1024` in a 2 workers cluster would have a maximum memory usage of 3gb (1 coordinator + 2 workers). + +Our plan for addressing this is to _disable clustering if a user has `max-old-space-size` set at all_, which would ensure it isn't +possible to hit unpredictable behavior. To enable clustering, the user would simply remove `max-old-space-size` settings, and +clustering would be on by default. They could alternatively configure memory settings for each worker individually, as shown above. + +### 6.1.8 Workers error handling + +When using `cluster`, the common best practice is to have the coordinator recreate ('restart') workers when they terminate unexpectedly. +However, given Kibana's architecture, some failures are not recoverable (workers failing because of config validation, failed migration...). + +For instance, if a worker (well, all workers) terminates because of an invalid configuration property, it doesn't make +any sense to have the coordinator recreate them indefinitely, as the error requires manual intervention. + +As a first step, we plan to terminate the main Kibana process when any worker terminates unexpectedly for any reason (after all, +this is already the behavior in non-cluster mode). In the future, we will look toward distinguishing between recoverable +and non-recoverable errors as an enhancement, so that we can automatically restart workers on any recoverable error. + +### 6.1.9 Data folder + +The data folder (`path.data`) is currently the same for all workers. + +We still have to identify with the teams if this is going to be a problem. It could be, for example, if some plugins +are accessing files in write mode, which could result in concurrency issues between the workers. + +If that was confirmed, we would plan to create and use a distinct data folder for each worker, which would be non-breaking +as we don't consider the layout of this directory to be part of our public API. + +### 6.1.10 instanceUUID + +The same instance UUID (`server.uuid` / `{dataFolder}/uuid`) is currently used by all the workers. + +So far, we have not identified any places where this will be problematic, however, we will look to other teams to +help validate this. + +Note that if we did need to have per-worker UUIDs, this could be a breaking change, as the single `server.uuid` +configuration property would not be enough. If this change becomes necessary, one approach could be to have unique worker +IDs with `${serverUuid}-${workerId}`. + +## 6.2 Technical impact on Plugins + +### 6.2.1 What types of things could break? + +#### Concurrent access to the same resources + +Is there, for example, some part of the code that is accessing and writing files from the data folder (or anywhere else) +and makes the assumption that it is the sole process actually writing to that file? + +#### Using instanceUUID as a unique Kibana process identifier + +Is there, for example, schedulers that are using the instanceUUID a single process id, in opposition to a single +Kibana instance id? Are there situations where having the same instance UUID for all the workers is going to be a problem? + +#### Things needing to run only once per Kibana instance + +Is there any part of the code that needs to be executed only once in a multi-worker mode, such as initialization code, +or starting schedulers? + +An example would be Reporting's queueFactory polling. As we want to only be running a single headless at a time per +Kibana instance, only one worker should have polling enabled. + +### 6.2.2 Identified required changes + +#### Reporting + +We will probably want to restrict to a single headless per Kibana instance. For that, we will have to change the logic +in [createQueueFactory](https://github.com/elastic/kibana/blob/4584a8b570402aa07832cf3e5b520e5d2cfa7166/x-pack/plugins/reporting/server/lib/create_queue.ts#L60-L64) +to only have the 'main' worker be polling for reporting tasks. + +#### Telemetry + +- Server side fetcher + +The telemetry/server/fetcher.ts will attempt sending the telemetry usage multiple times once per day from each process. +We do store a state in the SavedObjects store of the last time the usage was sent to prevent sending multiple times +(although race conditions might occur). + +- Tasks storing telemetry data + +We have tasks across several plugins storing data in savedobjects specifically for telemetry. Under clustering these +tasks will be registered multiple times. + +Note that sending the data multiple times doesn’t have any real consequences, apart from the additional number of ES requests, +so this should be considered non-blocking and only an improvement. + +- Event-based telemetry + +Event-based telemetry may be affected as well. Both the existing one in the Security Solutions team and the general +one that is in the works. More specifically, the size of the queues will be multiplied per worker, also growing in the +amount of network bandwidth used, and potentially affecting our customers. + +We could address that by making sure that the queues are held only in the main worker. + +#### Task Manager + +Currently, task manager does "claims" for jobs to run based on the server uuid. We think this could still work with +a multi-process setup - each task manager in the worker would be doing "claims" for the same server uuid, which +seems functionally the same as setting max_workers to `current max_workers * number of workers`. +Another alternative would be to compose something like `${server.uuid}-${worker.Id}`, as TM only +really needs a unique identifier. + +However, as a first step we can simply run Task Manager on the main worker. This doesn't completely solve potential +noisy neighbor problems as the main worker will still be receiving & serving http requests, however it will at least +ensure that other worker processes are free to serve http requests without risk of TM interference. Long term, we +could explore manually spawning a dedicated child process for background tasks that can be called from workers, and +thinking of a way for plugins to tell Core when they need to run things in the background. + +It would be ideal if we could eventually solve this with our multi-process setup, however this needs more design work +and could necessitate an RFC in its own right. The key thing to take away here is that the work we are doing in this +RFC would not prevent us from exploring this path further in a subsequent phase. In fact, it could prove to be a +helpful first step in that direction. + +#### Alerting + +Currently haven't identified any Alerting-specific requirements that aren't already covered by the +Task Manager requirements. + +## 6.3 Summary of breaking changes + +### 6.3.1 `/stats` API & metrics service + +Currently the only breaking change we have identified is for the `/stats` API. + +The `process` memory usage reported doesn't really make sense in a multi-process Kibana, and +even though we have a plan to aggregate this data as a temporary solution (see 6.1.4), this +could still lead to confusion for users as it doesn't paint a clear picture of the state of the system. + +Our plan is to deprecate the `process` field, and later remove it or change the structure +to better support a multi-process Kibana. + +# 7. Drawbacks + +- Implementation cost is going to be significant as this will require multiple phases, both in core and in plugins. + Also, this will have to be a collaborative effort, as we can't enable cluster mode in production until all of the + identified breaking changes have been addressed. +- Even if it is easier to deploy, at a technical level it doesn't really provide anything more than a multi-instance Kibana setup. +- This will add complexity to the code, especially in Core where some parts of the logic will drastically diverge between + clustered and non-clustered modes (most notably our logging system). +- There is a risk of introducing subtle bugs in clustered mode, as we may overlook some breaking changes, or developers + may neglect to ensure clustered mode compatibility when adding new features. +- Proper testing of all the edge cases is going to be tedious, and in some cases realistically impossible. Proper + education of developers is going to be critical to ensure we are building new features with clustering in mind. + +# 8. Alternatives + +One alternative to the `cluster` module is using a worker pool via `worker_threads`. Both have distinct use cases +though. Clustering is meant to have multiple workers with the same codebase, often sharing a network socket to balance +network traffic. Worker threads is a way to create specialized workers in charge of executing isolated, CPU intensive +tasks on demand (e.g. encrypting or descrypting a file). If we were to identify that under heavy load, the actual bottleneck +is ES, maybe exposing a worker thread service and API from Core (task_manager would be a perfect example of potential consumer) +would make more sense. + +However, we believe the simplicity and broad acceptance of the `cluster` API in the Node community makes it the +better approach over `worker_threads`, and would prefer to only go down the road of a worker pool as a last resort. + +Another alternative would be to provide tooling to ease the deployment of multi-instance Kibana setups, and only support +multi-instance mode moving forward. + +# 9. Adoption strategy + +Because the changes proposed in this RFC touch the lowest levels of Kibana's core, and therefore have potential to impact +large swaths of Kibana's codebase, we propse a multi-phase strategy: + +## Phase 0 +In the prepratory phase, we will evolve the existing POC to validate the finer details of this RFC, while also putting together +a more detailed testing strategy that can be used to benchmark our future work. + +## Phase 1 +To start implementation, we will make the required changes in Core, adding the `node.enabled` configuration property. +At first, we'll include a big warning in the logs to make it clear that this shouldn't be used in production yet. +This way, we allow developers to test their features against clustering mode and to adapt their code +to use the new `node` API and service. At this point we will also aim to document any identified breaking changes and +add deprecation notices where applicable, to allow developers time to prepare for 8.0. + +## Phase 2 +When all the required changes have been performed in plugin code, we will enable the `node` configuration on production +mode as a `beta` feature. We would ideally also add telemetry collection for the clustering usages (relevant metrics TBD) +to have a precise vision of the adoption of the feature. + +## Phase 3 +Once the new feature has been validated and we are comfortable considering it GA, we will enable `node` by default. +(We could alternatively enable it by default from the outset, still with a `beta` label). + +# 10. How we teach this + +During Phase 1, we should create documentation on the clustering mode: best practices, how to identify code that may break in +clustered mode, and so on. + +We will specifically look to make changes to our docs around contributing to Kibana, specifically we can add a section +in the [best practices](https://www.elastic.co/guide/en/kibana/master/development-best-practices.html#_testing_stability) to +remind contributers to be thinking about the fact that you cannot rely on a 1:1 relationship between the Kibana process and +an individual machine. + +Lastly, we'll take advantage of internal communications to kibana-contributors, and make an effort to individually check in +with the teams who we think will most likely be affected by these changes. + +# 11. Unresolved questions + +**Are breaking changes required for the `/stats` API & metrics service?** + +See 6.1.4 above. + +# 12. Resolved questions + +**How do we handle http requests that need to be served by a specific process?** + +The Node.js cluster API is not really the right solution for this, as it won't allow for custom scheduling policies. A custom scheduling policy would basically mean re-implementing the cluster API on our own. At this point we will not be solving this particular issue with the clustering project, however the abstraction proposed in this RFC will not preclude us from changing out the underlying implementation in the future should we choose to do so.  + +**How do we handle http requests that need to have knowledge of all processes?** + +`/status` and `/stats` are the big issues here, as they could be reported differently from each process. The current plan is to manage their state centrally in the coordinator and have each process report this data at a regular interval, so that all processes can retrieve it and serve it in response to any requests against that endpoint. Exact details of the changes to those APIs would need to be determined. I think `status` will likely require breaking changes as pointed out above, however `stats` may not. + +**Is it okay for the workers to share the same `path.data` directory?** + +We have been unable to identify any plugins which are writing to this directory. +The App Services team has confirmed that `path.data` is no longer in use in the reporting plugin. + +**Is using the same `server.uuid` in each worker going to cause problems?** + +We have been unable to identify any plugins for which this would cause issues. +The Alerting team has confirmed that Task Manager doesn't need server uuid, just a unique +identifier. That means something like server.uuid + worker.id would work. diff --git a/scripts/spec_to_console.js b/scripts/spec_to_console.js index cbb152f55f8fb..37e246323a11f 100644 --- a/scripts/spec_to_console.js +++ b/scripts/spec_to_console.js @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -require('../packages/kbn-spec-to-console/bin/spec_to_console'); +require('@kbn/spec-to-console/bin/spec_to_console'); diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 3ed164088bf5c..de9e4d4496f3b 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -15,13 +15,13 @@ import { import { createElement } from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; -import { shallow, mount } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { httpServiceMock } from '../http/http_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; -import { App, PublicAppInfo, AppNavLinkStatus, AppStatus, AppUpdater } from './types'; +import { App, AppDeepLink, AppNavLinkStatus, AppStatus, AppUpdater, PublicAppInfo } from './types'; import { act } from 'react-dom/test-utils'; const createApp = (props: Partial): App => { @@ -365,6 +365,85 @@ describe('#setup()', () => { expect(MockHistory.push).toHaveBeenCalledWith('/app/app1', undefined); MockHistory.push.mockClear(); }); + + it('preserves the deep links if the update does not modify them', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + const updater$ = new BehaviorSubject((app) => ({})); + + const deepLinks: AppDeepLink[] = [ + { + id: 'foo', + title: 'Foo', + searchable: true, + navLinkStatus: AppNavLinkStatus.visible, + path: '/foo', + }, + { + id: 'bar', + title: 'Bar', + searchable: false, + navLinkStatus: AppNavLinkStatus.hidden, + path: '/bar', + }, + ]; + + setup.register(pluginId, createApp({ id: 'app1', deepLinks, updater$ })); + + const { applications$ } = await service.start(startDeps); + + updater$.next((app) => ({ defaultPath: '/foo' })); + + let appInfos = await applications$.pipe(take(1)).toPromise(); + + expect(appInfos.get('app1')!.deepLinks).toEqual([ + { + deepLinks: [], + id: 'foo', + keywords: [], + navLinkStatus: 1, + path: '/foo', + searchable: true, + title: 'Foo', + }, + { + deepLinks: [], + id: 'bar', + keywords: [], + navLinkStatus: 3, + path: '/bar', + searchable: false, + title: 'Bar', + }, + ]); + + updater$.next((app) => ({ + deepLinks: [ + { + id: 'bar', + title: 'Bar', + searchable: false, + navLinkStatus: AppNavLinkStatus.hidden, + path: '/bar', + }, + ], + })); + + appInfos = await applications$.pipe(take(1)).toPromise(); + + expect(appInfos.get('app1')!.deepLinks).toEqual([ + { + deepLinks: [], + id: 'bar', + keywords: [], + navLinkStatus: 3, + path: '/bar', + searchable: false, + title: 'Bar', + }, + ]); + }); }); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 8c6090caabce1..2e804bf2f5413 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -54,6 +54,7 @@ function filterAvailable(m: Map, capabilities: Capabilities) { ) ); } + const findMounter = (mounters: Map, appRoute?: string) => [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); @@ -414,13 +415,11 @@ const updateStatus = (app: App, statusUpdaters: AppUpdaterWrapper[]): App => { changes.navLinkStatus ?? AppNavLinkStatus.default, fields.navLinkStatus ?? AppNavLinkStatus.default ), - // deepLinks take the last defined update - deepLinks: fields.deepLinks - ? populateDeepLinkDefaults(fields.deepLinks) - : changes.deepLinks, + ...(fields.deepLinks ? { deepLinks: populateDeepLinkDefaults(fields.deepLinks) } : {}), }; } }); + return { ...app, ...changes, diff --git a/src/core/public/application/utils/get_app_info.test.ts b/src/core/public/application/utils/get_app_info.test.ts index fa1e2dd9a4537..25614d1d1dca9 100644 --- a/src/core/public/application/utils/get_app_info.test.ts +++ b/src/core/public/application/utils/get_app_info.test.ts @@ -185,15 +185,18 @@ describe('getAppInfo', () => { it('adds default deepLinks when needed', () => { const app = createApp({ + order: 3, deepLinks: [ { id: 'sub-id', title: 'sub-title', + order: 2, deepLinks: [ { id: 'sub-sub-id', title: 'sub-sub-title', path: '/sub-sub', + order: 1, keywords: ['sub sub'], }, ], @@ -210,12 +213,14 @@ describe('getAppInfo', () => { searchable: true, appRoute: `/app/some-id`, keywords: [], + order: 3, deepLinks: [ { id: 'sub-id', title: 'sub-title', navLinkStatus: AppNavLinkStatus.hidden, searchable: true, + order: 2, keywords: [], deepLinks: [ { @@ -223,6 +228,7 @@ describe('getAppInfo', () => { title: 'sub-sub-title', navLinkStatus: AppNavLinkStatus.hidden, searchable: true, + order: 1, path: '/sub-sub', keywords: ['sub sub'], deepLinks: [], diff --git a/src/core/public/application/utils/get_app_info.ts b/src/core/public/application/utils/get_app_info.ts index 6c753b7a71a0f..b5a3f0b0a0f13 100644 --- a/src/core/public/application/utils/get_app_info.ts +++ b/src/core/public/application/utils/get_app_info.ts @@ -41,9 +41,7 @@ function getDeepLinkInfos(deepLinks?: AppDeepLink[]): PublicAppDeepLinkInfo[] { return deepLinks.map( ({ navLinkStatus = AppNavLinkStatus.default, ...rawDeepLink }): PublicAppDeepLinkInfo => { return { - id: rawDeepLink.id, - title: rawDeepLink.title, - path: rawDeepLink.path, + ...rawDeepLink, keywords: rawDeepLink.keywords ?? [], navLinkStatus: navLinkStatus === AppNavLinkStatus.default ? AppNavLinkStatus.hidden : navLinkStatus, diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 0264c8a1acf75..92f5a854f6b00 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -53,8 +53,21 @@ function defaultStartDeps(availableApps?: App[]) { return deps; } +function defaultStartTestOptions({ + browserSupportsCsp = true, + kibanaVersion = 'version', +}: { + browserSupportsCsp?: boolean; + kibanaVersion?: string; +}): any { + return { + browserSupportsCsp, + kibanaVersion, + }; +} + async function start({ - options = { browserSupportsCsp: true }, + options = defaultStartTestOptions({}), cspConfigMock = { warnLegacyBrowsers: true }, startDeps = defaultStartDeps(), }: { options?: any; cspConfigMock?: any; startDeps?: ReturnType } = {}) { @@ -82,7 +95,9 @@ afterAll(() => { describe('start', () => { it('adds legacy browser warning if browserSupportsCsp is disabled and warnLegacyBrowsers is enabled', async () => { - const { startDeps } = await start({ options: { browserSupportsCsp: false } }); + const { startDeps } = await start({ + options: { browserSupportsCsp: false, kibanaVersion: '7.0.0' }, + }); expect(startDeps.notifications.toasts.addWarning.mock.calls).toMatchInlineSnapshot(` Array [ @@ -95,6 +110,41 @@ describe('start', () => { `); }); + it('adds the kibana versioned class to the document body', async () => { + const { chrome, service } = await start({ + options: { browserSupportsCsp: false, kibanaVersion: '1.2.3' }, + }); + const promise = chrome.getBodyClasses$().pipe(toArray()).toPromise(); + service.stop(); + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + Array [ + "kbnBody", + "kbnBody--noHeaderBanner", + "kbnBody--chromeHidden", + "kbnVersion-1-2-3", + ], + ] + `); + }); + it('strips off "snapshot" from the kibana version if present', async () => { + const { chrome, service } = await start({ + options: { browserSupportsCsp: false, kibanaVersion: '8.0.0-SnAPshot' }, + }); + const promise = chrome.getBodyClasses$().pipe(toArray()).toPromise(); + service.stop(); + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + Array [ + "kbnBody", + "kbnBody--noHeaderBanner", + "kbnBody--chromeHidden", + "kbnVersion-8-0-0", + ], + ] + `); + }); + it('does not add legacy browser warning if browser supports CSP', async () => { const { startDeps } = await start(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 5ed447edde75a..f1381c52ce779 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -37,9 +37,11 @@ import { export type { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; const IS_LOCKED_KEY = 'core.chrome.isLocked'; +const SNAPSHOT_REGEX = /-snapshot/i; interface ConstructorParams { browserSupportsCsp: boolean; + kibanaVersion: string; } interface StartDeps { @@ -116,6 +118,16 @@ export class ChromeService { const helpSupportUrl$ = new BehaviorSubject(KIBANA_ASK_ELASTIC_LINK); const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true'); + const getKbnVersionClass = () => { + // we assume that the version is valid and has the form 'X.X.X' + // strip out `SNAPSHOT` and reformat to 'X-X-X' + const formattedVersionClass = this.params.kibanaVersion + .replace(SNAPSHOT_REGEX, '') + .split('.') + .join('-'); + return `kbnVersion-${formattedVersionClass}`; + }; + const headerBanner$ = new BehaviorSubject(undefined); const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!]).pipe( map(([headerBanner, isVisible]) => { @@ -123,6 +135,7 @@ export class ChromeService { 'kbnBody', headerBanner ? 'kbnBody--hasHeaderBanner' : 'kbnBody--noHeaderBanner', isVisible ? 'kbnBody--chromeVisible' : 'kbnBody--chromeHidden', + getKbnVersionClass(), ]; }) ); diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 3668829a6888c..0b10209bc13e5 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -370,54 +370,62 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` isOpen={true} onClose={[Function]} > - - - - } - /> - - -
-
+ + +
+
+ +
-
-
-
- - - -
+ data-euiicon-type="home" + /> + + + Home + + + + + +
-
-
- - + +
+
+ + + + +

+ Recently viewed +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

- Recently viewed -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ recent 2 + + + + + +
- -
+
+
-
-
- -
-
- + + + +
+
+ +
-
- + + + + + +

+ Analytics +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-kibana" - iconType="logoKibana" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="kibana" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Analytics" + paddingSize="none" > - - - - - - -

- Analytics -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ dashboard + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Observability +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-observability" - iconType="logoObservability" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="observability" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Observability" + paddingSize="none" > - - - - - - -

- Observability -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ logs + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Security +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-securitySolution" - iconType="logoSecurity" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="securitySolution" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Security" + paddingSize="none" > - - - - - - -

- Security -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ siem + + + + + +
- -
+
+
-
-
- + + + + + + + + + +

+ Management +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-management" - iconType="managementApp" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="management" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Management" + paddingSize="none" > - - - - - - -

- Management -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
-
- -
-
+ +
+ +
+ + + +
+
+ - -
+
+
-
- - - -
+ monitoring + + + + + +
- -
+
+
-
-
- + + + +
-
- - - -
+ canvas + + + + + +
- - - +
+
+ + +
-
- -
    - - - - Dock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lockOpen" - label="Dock navigation" - onClick={[Function]} - size="xs" - > -
  • - -
  • -
    -
-
-
+ , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lockOpen" + label="Dock navigation" + onClick={[Function]} + size="xs" + > +
  • + +
  • + + +
    - - -
    - - - - - - - -
    - +
    + + + +
    + + `; @@ -2770,42 +2706,57 @@ exports[`CollapsibleNav renders the default nav 3`] = ` isOpen={false} onClose={[Function]} > - - -
    -
    +
    + + + + +

    + Recently viewed +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="generated-id" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
    -
    - -
    -
    + +
    + +
    + + + +
    +
    + - -
    +
    +
    -
    - -
    - -
    -

    - No recently viewed items -

    -
    -
    -
    -
    -
    +

    + No recently viewed items +

    +
    + +
    +
    -
    -
    + + -
    -
    - -
    -
    - + + + +
    +
    + +
    -
    - - + +
    -
    - -
      - - - - Undock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lock" - label="Undock navigation" - onClick={[Function]} - size="xs" - > -
    • - -
    • -
      -
    -
    -
    + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lock" + label="Undock navigation" + onClick={[Function]} + size="xs" + > +
  • + +
  • + + +
    - - -
    - - - - - - - -
    - +
    + + + +
    + + `; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 6ad1e2d3a1cc6..5aee9ca1b7c08 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -4947,42 +4947,57 @@ exports[`Header renders 1`] = ` isOpen={false} onClose={[Function]} > - - -
    -
    +
    + +
    +
    + +
    -
    -
    -
    - - - -
    + data-euiicon-type="home" + /> + + + Home + + + + + +
    -
    -
    - - + +
    +
    + + + + +

    + Recently viewed +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" initialIsOpen={true} - isCollapsible={true} - key="recentlyViewed" + isLoading={false} + isLoadingMessage={false} onToggle={[Function]} - title="Recently viewed" + paddingSize="none" > - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" +
    -
    - -
    -
    + +
    + +
    + + + +
    +
    + - -
    +
    +
    -
    - - - -
    + dashboard + + + + + +
    - -
    +
    +
    -
    -
    - -
    -
    - + + + +
    +
    + +
    -
    - +
    + +
      + +
    • + +
    • +
      +
    +
    +
    +
    + + +
    + + + Undock navigation + + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lock" + label="Undock navigation" onClick={[Function]} - size="s" + size="xs" >
  • @@ -5445,163 +5540,11 @@ exports[`Header renders 1`] = `
    - - -
    -
    - -
      - - - - Undock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lock" - label="Undock navigation" - onClick={[Function]} - size="xs" - > -
    • - -
    • -
      -
    -
    -
    -
    -
    -
    -
    -
    - - - - - - - -
    + + + + + diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 7f338a859e7b4..460770744d53a 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -16,10 +16,6 @@ import { httpServiceMock } from '../../../http/http_service.mock'; import { ChromeRecentlyAccessedHistoryItem } from '../../recently_accessed'; import { CollapsibleNav } from './collapsible_nav'; -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: () => () => 'mockId', -})); - const { kibana, observability, security, management } = DEFAULT_APP_CATEGORIES; function mockLink({ title = 'discover', category }: Partial) { diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index fdbdde8556eeb..a3a0197b4017e 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -99,7 +99,7 @@ describe('Header', () => { act(() => isLocked$.next(true)); component.update(); - expect(component.find('nav[aria-label="Primary"]').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="collapsibleNav"]').exists()).toBeTruthy(); expect(component).toMatchSnapshot(); act(() => diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 67cdd24aae848..246ca83ef5ade 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -87,6 +87,7 @@ export function Header({ const isVisible = useObservable(observables.isVisible$, false); const isLocked = useObservable(observables.isLocked$, false); const [isNavOpen, setIsNavOpen] = useState(false); + const [navId] = useState(htmlIdGenerator()()); const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$); if (!isVisible) { @@ -99,7 +100,6 @@ export function Header({ } const toggleCollapsibleNavRef = createRef void }>(); - const navId = htmlIdGenerator()(); const className = classnames('hide-for-sharing', 'headerGlobalNav'); const Breadcrumbs = ( diff --git a/src/core/public/core_app/core_app.ts b/src/core/public/core_app/core_app.ts index aa0223dbe08a7..00532b9150aef 100644 --- a/src/core/public/core_app/core_app.ts +++ b/src/core/public/core_app/core_app.ts @@ -18,8 +18,13 @@ import type { CoreContext } from '../core_system'; import type { NotificationsSetup, NotificationsStart } from '../notifications'; import type { IUiSettingsClient } from '../ui_settings'; import type { InjectedMetadataSetup } from '../injected_metadata'; -import { renderApp as renderErrorApp, setupUrlOverflowDetection } from './errors'; +import { + renderApp as renderErrorApp, + setupPublicBaseUrlConfigWarning, + setupUrlOverflowDetection, +} from './errors'; import { renderApp as renderStatusApp } from './status'; +import { DocLinksStart } from '../doc_links'; interface SetupDeps { application: InternalApplicationSetup; @@ -30,6 +35,7 @@ interface SetupDeps { interface StartDeps { application: InternalApplicationStart; + docLinks: DocLinksStart; http: HttpStart; notifications: NotificationsStart; uiSettings: IUiSettingsClient; @@ -40,7 +46,7 @@ export class CoreApp { constructor(private readonly coreContext: CoreContext) {} - public setup({ http, application, injectedMetadata, notifications }: SetupDeps) { + public setup({ application, http, injectedMetadata, notifications }: SetupDeps) { application.register(this.coreContext.coreId, { id: 'error', title: 'App Error', @@ -68,7 +74,7 @@ export class CoreApp { }); } - public start({ application, http, notifications, uiSettings }: StartDeps) { + public start({ application, docLinks, http, notifications, uiSettings }: StartDeps) { if (!application.history) { return; } @@ -79,6 +85,8 @@ export class CoreApp { toasts: notifications.toasts, uiSettings, }); + + setupPublicBaseUrlConfigWarning({ docLinks, http, notifications }); } public stop() { diff --git a/src/core/public/core_app/errors/index.ts b/src/core/public/core_app/errors/index.ts index 02666103de349..e991fa455ab31 100644 --- a/src/core/public/core_app/errors/index.ts +++ b/src/core/public/core_app/errors/index.ts @@ -8,3 +8,4 @@ export { renderApp } from './error_application'; export { setupUrlOverflowDetection, URL_MAX_LENGTH } from './url_overflow'; +export { setupPublicBaseUrlConfigWarning } from './public_base_url'; diff --git a/src/core/public/core_app/errors/public_base_url.test.tsx b/src/core/public/core_app/errors/public_base_url.test.tsx new file mode 100644 index 0000000000000..d1fb5a5093f15 --- /dev/null +++ b/src/core/public/core_app/errors/public_base_url.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { docLinksServiceMock } from '../../doc_links/doc_links_service.mock'; +import { httpServiceMock } from '../../http/http_service.mock'; +import { notificationServiceMock } from '../../notifications/notifications_service.mock'; + +import { setupPublicBaseUrlConfigWarning } from './public_base_url'; + +describe('publicBaseUrl warning', () => { + const docLinks = docLinksServiceMock.createStartContract(); + const notifications = notificationServiceMock.createStartContract(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('does not show any toast on localhost', () => { + const http = httpServiceMock.createStartContract(); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: 'localhost', + } as Location, + }); + + expect(notifications.toasts.addWarning).not.toHaveBeenCalled(); + }); + + it('does not show any toast on 127.0.0.1', () => { + const http = httpServiceMock.createStartContract(); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: '127.0.0.1', + } as Location, + }); + + expect(notifications.toasts.addWarning).not.toHaveBeenCalled(); + }); + + it('does not show toast if configured correctly', () => { + const http = httpServiceMock.createStartContract({ publicBaseUrl: 'http://myhost.com' }); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: 'myhost.com', + toString() { + return 'http://myhost.com/'; + }, + } as Location, + }); + + expect(notifications.toasts.addWarning).not.toHaveBeenCalled(); + }); + + describe('config missing toast', () => { + it('adds toast if publicBaseUrl is missing', () => { + const http = httpServiceMock.createStartContract({ publicBaseUrl: undefined }); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: 'myhost.com', + toString() { + return 'http://myhost.com/'; + }, + } as Location, + }); + + expect(notifications.toasts.addWarning).toHaveBeenCalledWith({ + title: 'Configuration missing', + text: expect.any(Function), + }); + }); + + it('does not add toast if storage key set', () => { + const http = httpServiceMock.createStartContract({ publicBaseUrl: undefined }); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: 'myhost.com', + toString() { + return 'http://myhost.com/'; + }, + } as Location, + storage: { + getItem: (id: string) => 'true', + } as Storage, + }); + + expect(notifications.toasts.addWarning).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/public/core_app/errors/public_base_url.tsx b/src/core/public/core_app/errors/public_base_url.tsx new file mode 100644 index 0000000000000..263367a4cb09a --- /dev/null +++ b/src/core/public/core_app/errors/public_base_url.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import type { HttpStart, NotificationsStart } from '../..'; +import type { DocLinksStart } from '../../doc_links'; +import { mountReactNode } from '../../utils'; + +/** Only exported for tests */ +export const MISSING_CONFIG_STORAGE_KEY = `core.warnings.publicBaseUrlMissingDismissed`; + +interface Deps { + docLinks: DocLinksStart; + http: HttpStart; + notifications: NotificationsStart; + // Exposed for easier testing + storage?: Storage; + location?: Location; +} + +export const setupPublicBaseUrlConfigWarning = ({ + docLinks, + http, + notifications, + storage = window.localStorage, + location = window.location, +}: Deps) => { + if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { + return; + } + + const missingWarningSeen = storage.getItem(MISSING_CONFIG_STORAGE_KEY) === 'true'; + if (missingWarningSeen || http.basePath.publicBaseUrl) { + return; + } + + const toast = notifications.toasts.addWarning({ + title: i18n.translate('core.ui.publicBaseUrlWarning.configMissingTitle', { + defaultMessage: 'Configuration missing', + }), + text: mountReactNode( + <> +

    + server.publicBaseUrl, + }} + />{' '} + + + +

    + + + + { + notifications.toasts.remove(toast); + storage.setItem(MISSING_CONFIG_STORAGE_KEY, 'true'); + }} + id="mute" + > + + + + + + ), + }); +}; diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 1c4e78f0a5c2e..8ead0f50785bd 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -46,6 +46,7 @@ const defaultCoreSystemParams = { csp: { warnLegacyBrowsers: true, }, + version: 'version', } as any, }; @@ -91,12 +92,12 @@ describe('constructor', () => { }); }); - it('passes browserSupportsCsp to ChromeService', () => { + it('passes browserSupportsCsp and coreContext to ChromeService', () => { createCoreSystem(); - expect(ChromeServiceConstructor).toHaveBeenCalledTimes(1); expect(ChromeServiceConstructor).toHaveBeenCalledWith({ - browserSupportsCsp: expect.any(Boolean), + browserSupportsCsp: true, + kibanaVersion: 'version', }); }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index f0ea1e62fc33f..e5dcd8f817a0a 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { CoreId } from '../server'; import { PackageInfo, EnvironmentMode } from '../server/types'; import { CoreSetup, CoreStart } from '.'; @@ -98,6 +97,7 @@ export class CoreSystem { this.injectedMetadata = new InjectedMetadataService({ injectedMetadata, }); + this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; this.fatalErrors = new FatalErrorsService(rootDomElement, () => { // Stop Core before rendering any fatal errors into the DOM @@ -109,14 +109,16 @@ export class CoreSystem { this.savedObjects = new SavedObjectsService(); this.uiSettings = new UiSettingsService(); this.overlay = new OverlayService(); - this.chrome = new ChromeService({ browserSupportsCsp }); + this.chrome = new ChromeService({ + browserSupportsCsp, + kibanaVersion: injectedMetadata.version, + }); this.docLinks = new DocLinksService(); this.rendering = new RenderingService(); this.application = new ApplicationService(); this.integrations = new IntegrationsService(); this.deprecations = new DeprecationsService(); - this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; this.plugins = new PluginsService(this.coreContext, injectedMetadata.uiPlugins); this.coreApp = new CoreApp(this.coreContext); } @@ -200,7 +202,7 @@ export class CoreSystem { }); const deprecations = this.deprecations.start({ http }); - this.coreApp.start({ application, http, notifications, uiSettings }); + this.coreApp.start({ application, docLinks, http, notifications, uiSettings }); const core: InternalCoreStart = { application, diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 95091a761639b..43c21b37ee298 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -29,6 +29,7 @@ export class DocLinksService { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL, links: { + settings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/settings.html`, canvas: { guide: `${KIBANA_DOCS}canvas.html`, }, @@ -137,6 +138,7 @@ export class DocLinksService { addData: `${KIBANA_DOCS}connect-to-elasticsearch.html`, kibana: `${KIBANA_DOCS}index.html`, upgradeAssistant: `${KIBANA_DOCS}upgrade-assistant.html`, + rollupJobs: `${KIBANA_DOCS}data-rollups.html`, elasticsearch: { docsBase: `${ELASTICSEARCH_DOCS}`, asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`, @@ -203,6 +205,7 @@ export class DocLinksService { }, search: { sessions: `${KIBANA_DOCS}search-sessions.html`, + sessionLimits: `${KIBANA_DOCS}search-sessions.html#_limitations`, }, date: { dateMath: `${ELASTICSEARCH_DOCS}common-options.html#date-math`, @@ -424,6 +427,7 @@ export interface DocLinksStart { readonly DOC_LINK_VERSION: string; readonly ELASTIC_WEBSITE_URL: string; readonly links: { + readonly settings: string; readonly canvas: { readonly guide: string; }; @@ -522,6 +526,7 @@ export interface DocLinksStart { }; readonly search: { readonly sessions: string; + readonly sessionLimits: string; }; readonly indexPatterns: { readonly introduction: string; @@ -532,6 +537,7 @@ export interface DocLinksStart { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 61f501c844f30..fff99d84a76a6 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -18,7 +18,10 @@ export type HttpSetupMock = jest.Mocked & { anonymousPaths: jest.Mocked; }; -const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ +const createServiceMock = ({ + basePath = '', + publicBaseUrl, +}: { basePath?: string; publicBaseUrl?: string } = {}): HttpSetupMock => ({ fetch: jest.fn(), get: jest.fn(), head: jest.fn(), @@ -27,7 +30,7 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ patch: jest.fn(), delete: jest.fn(), options: jest.fn(), - basePath: new BasePath(basePath), + basePath: new BasePath(basePath, undefined, publicBaseUrl), anonymousPaths: { register: jest.fn(), isAnonymous: jest.fn(), diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 32737ff427ef3..9bf1a05abc34e 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -99,6 +99,7 @@ export type { } from './application'; export { SimpleSavedObject } from './saved_objects'; +export type { ResolvedSimpleSavedObject } from './saved_objects'; export type { SavedObjectsBatchResponse, SavedObjectsBulkCreateObject, @@ -107,6 +108,7 @@ export type { SavedObjectsBulkUpdateOptions, SavedObjectsCreateOptions, SavedObjectsFindResponsePublic, + SavedObjectsResolveResponse, SavedObjectsUpdateOptions, SavedObject, SavedObjectAttribute, diff --git a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap index f5a1c51ccbe15..fbd09f3096854 100644 --- a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap +++ b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap @@ -26,7 +26,7 @@ Array [ ] `; -exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
    Flyout content
    "`; +exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
    Flyout content
    "`; exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = ` Array [ @@ -59,4 +59,4 @@ Array [ ] `; -exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
    Flyout content 2
    "`; +exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
    Flyout content 2
    "`; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 6cc2b3f321fb7..8b87c21e22fa4 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -487,6 +487,7 @@ export interface DocLinksStart { readonly ELASTIC_WEBSITE_URL: string; // (undocumented) readonly links: { + readonly settings: string; readonly canvas: { readonly guide: string; }; @@ -585,6 +586,7 @@ export interface DocLinksStart { }; readonly search: { readonly sessions: string; + readonly sessionLimits: string; }; readonly indexPatterns: { readonly introduction: string; @@ -595,6 +597,7 @@ export interface DocLinksStart { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; @@ -1115,6 +1118,13 @@ export type ResolveDeprecationResponse = { reason: string; }; +// @public +export interface ResolvedSimpleSavedObject { + aliasTargetId?: SavedObjectsResolveResponse['aliasTargetId']; + outcome: SavedObjectsResolveResponse['outcome']; + savedObject: SimpleSavedObject; +} + // Warning: (ae-missing-release-tag) "SavedObject" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1244,6 +1254,7 @@ export class SavedObjectsClient { // Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts find: (options: SavedObjectsFindOptions_2) => Promise>; get: (type: string, id: string) => Promise>; + resolve: (type: string, id: string) => Promise>; update(type: string, id: string, attributes: T, { version, references, upsert }?: SavedObjectsUpdateOptions): Promise>; } @@ -1464,6 +1475,13 @@ export interface SavedObjectsMigrationVersion { // @public export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; +// @public (undocumented) +export interface SavedObjectsResolveResponse { + aliasTargetId?: string; + outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + saved_object: SavedObject; +} + // @public (undocumented) export interface SavedObjectsStart { // (undocumented) @@ -1501,7 +1519,7 @@ export class ScopedHistory implements History { - constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObject); + constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, }: SavedObject); // (undocumented) attributes: T; // (undocumented) @@ -1518,6 +1536,7 @@ export class SimpleSavedObject { id: SavedObject['id']; // (undocumented) migrationVersion: SavedObject['migrationVersion']; + namespaces: SavedObject['namespaces']; // (undocumented) references: SavedObject['references']; // (undocumented) @@ -1630,6 +1649,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // -// src/core/public/core_system.ts:166:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts +// src/core/public/core_system.ts:168:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index 4bd6afe90d342..92ba28ff70887 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -38,6 +38,7 @@ @mixin kbnAffordForHeader($headerHeight) { @include euiHeaderAffordForFixed($headerHeight); + #securitySolutionStickyKQL, #app-fixed-viewport { top: $headerHeight; } diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index cd75bc16f8362..bd22947b174b7 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -17,9 +17,11 @@ export type { SavedObjectsCreateOptions, SavedObjectsFindResponsePublic, SavedObjectsUpdateOptions, + SavedObjectsResolveResponse, SavedObjectsBulkUpdateOptions, } from './saved_objects_client'; export { SimpleSavedObject } from './simple_saved_object'; +export type { ResolvedSimpleSavedObject } from './types'; export type { SavedObjectsStart } from './saved_objects_service'; export type { SavedObjectsBaseOptions, diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index c2beef5b990c1..85441b9841eaf 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { SavedObjectsResolveResponse } from 'src/core/server'; + import { SavedObjectsClient } from './saved_objects_client'; import { SimpleSavedObject } from './simple_saved_object'; import { httpServiceMock } from '../http/http_service.mock'; @@ -147,6 +149,62 @@ describe('SavedObjectsClient', () => { }); }); + describe('#resolve', () => { + beforeEach(() => { + beforeEach(() => { + http.fetch.mockResolvedValue({ + saved_object: doc, + outcome: 'conflict', + aliasTargetId: 'another-id', + } as SavedObjectsResolveResponse); + }); + }); + + test('rejects if `type` is undefined', async () => { + expect(savedObjectsClient.resolve(undefined as any, doc.id)).rejects.toMatchInlineSnapshot( + `[Error: requires type and id]` + ); + }); + + test('rejects if `id` is undefined', async () => { + expect(savedObjectsClient.resolve(doc.type, undefined as any)).rejects.toMatchInlineSnapshot( + `[Error: requires type and id]` + ); + }); + + test('makes HTTP call', () => { + savedObjectsClient.resolve(doc.type, doc.id); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/resolve/config/AVwSwFxtcMV38qjDZoQg", + Object { + "body": undefined, + "method": undefined, + "query": undefined, + }, + ], + ] + `); + }); + + test('rejects when HTTP call fails', async () => { + http.fetch.mockRejectedValueOnce(new Error('Request failed')); + await expect(savedObjectsClient.resolve(doc.type, doc.id)).rejects.toMatchInlineSnapshot( + `[Error: Request failed]` + ); + }); + + test('resolves with ResolvedSimpleSavedObject instance', async () => { + const result = await savedObjectsClient.resolve(doc.type, doc.id); + expect(result.savedObject).toBeInstanceOf(SimpleSavedObject); + expect(result.savedObject.type).toBe(doc.type); + expect(result.savedObject.get('title')).toBe('Example title'); + expect(result.outcome).toBe('conflict'); + expect(result.aliasTargetId).toBe('another-id'); + }); + }); + describe('#delete', () => { beforeEach(() => { http.fetch.mockResolvedValue({}); diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 36ec3e734bd96..838b7adebc897 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -16,11 +16,15 @@ import { SavedObjectsClientContract as SavedObjectsApi, SavedObjectsFindOptions as SavedObjectFindOptionsServer, SavedObjectsMigrationVersion, + SavedObjectsResolveResponse, } from '../../server'; import { SimpleSavedObject } from './simple_saved_object'; +import type { ResolvedSimpleSavedObject } from './types'; import { HttpFetchOptions, HttpSetup } from '../http'; +export type { SavedObjectsResolveResponse }; + type PromiseType> = T extends Promise ? U : never; type SavedObjectsFindOptions = Omit< @@ -421,6 +425,29 @@ export class SavedObjectsClient { return request; } + /** + * Resolves a single object + * + * @param {string} type + * @param {string} id + * @returns The resolve result for the saved object for the given type and id. + */ + public resolve = ( + type: string, + id: string + ): Promise> => { + if (!type || !id) { + return Promise.reject(new Error('requires type and id')); + } + + const path = `${this.getPath(['resolve'])}/${type}/${id}`; + const request: Promise> = this.savedObjectsFetch(path, {}); + return request.then(({ saved_object: object, outcome, aliasTargetId }) => { + const savedObject = new SimpleSavedObject(this, object); + return { savedObject, outcome, aliasTargetId }; + }); + }; + /** * Updates an object * diff --git a/src/core/public/saved_objects/saved_objects_service.mock.ts b/src/core/public/saved_objects/saved_objects_service.mock.ts index 625ea6b5dd2da..2ceef1c077c39 100644 --- a/src/core/public/saved_objects/saved_objects_service.mock.ts +++ b/src/core/public/saved_objects/saved_objects_service.mock.ts @@ -18,6 +18,7 @@ const createStartContractMock = () => { bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + resolve: jest.fn(), update: jest.fn(), }, }; diff --git a/src/core/public/saved_objects/simple_saved_object.ts b/src/core/public/saved_objects/simple_saved_object.ts index b78890893c4ce..449d3d7943fca 100644 --- a/src/core/public/saved_objects/simple_saved_object.ts +++ b/src/core/public/saved_objects/simple_saved_object.ts @@ -30,6 +30,11 @@ export class SimpleSavedObject { public coreMigrationVersion: SavedObjectType['coreMigrationVersion']; public error: SavedObjectType['error']; public references: SavedObjectType['references']; + /** + * Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with + * `namespaceType: 'agnostic'`. + */ + public namespaces: SavedObjectType['namespaces']; constructor( private client: SavedObjectsClientContract, @@ -42,6 +47,7 @@ export class SimpleSavedObject { references, migrationVersion, coreMigrationVersion, + namespaces, }: SavedObjectType ) { this.id = id; @@ -51,6 +57,7 @@ export class SimpleSavedObject { this._version = version; this.migrationVersion = migrationVersion; this.coreMigrationVersion = coreMigrationVersion; + this.namespaces = namespaces; if (error) { this.error = error; } diff --git a/src/core/public/saved_objects/types.ts b/src/core/public/saved_objects/types.ts new file mode 100644 index 0000000000000..ac3df16730125 --- /dev/null +++ b/src/core/public/saved_objects/types.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsResolveResponse } from '../../server'; +import { SimpleSavedObject } from './simple_saved_object'; + +/** + * This interface is a very simple wrapper for SavedObjects resolved from the server + * with the {@link SavedObjectsClient}. + * + * @public + */ +export interface ResolvedSimpleSavedObject { + /** + * The saved object that was found. + */ + savedObject: SimpleSavedObject; + /** + * The outcome for a successful `resolve` call is one of the following values: + * + * * `'exactMatch'` -- One document exactly matched the given ID. + * * `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different + * than the given ID. + * * `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the + * `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + */ + outcome: SavedObjectsResolveResponse['outcome']; + /** + * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + */ + aliasTargetId?: SavedObjectsResolveResponse['aliasTargetId']; +} diff --git a/src/core/public/styles/_base.scss b/src/core/public/styles/_base.scss index 3386fa73f328a..de138cdf402e6 100644 --- a/src/core/public/styles/_base.scss +++ b/src/core/public/styles/_base.scss @@ -26,7 +26,7 @@ } .euiBody--collapsibleNavIsDocked .euiBottomBar { - margin-left: $euiCollapsibleNavWidth; + margin-left: 320px; // Hard-coded for now -- @cchaos } // Temporary fix for EuiPageHeader with a bottom border but no tabs or padding diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts index 5fa67fecb2a8a..a03f79096004b 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -139,6 +139,12 @@ const createStartContractMock = () => { storeSizeBytes: 1, }, ], + legacyUrlAliases: { + inactiveCount: 1, + activeCount: 1, + disabledCount: 1, + totalCount: 3, + }, }, }, }) diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index 95dd392016c17..2395d6d1c1725 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -183,6 +183,19 @@ describe('CoreUsageDataService', () => { }, ], } as any); + elasticsearch.client.asInternalUser.search.mockResolvedValueOnce({ + body: { + hits: { total: { value: 6 } }, + aggregations: { + aliases: { + buckets: { + active: { doc_count: 1 }, + disabled: { doc_count: 2 }, + }, + }, + }, + }, + } as any); const typeRegistry = savedObjectsServiceMock.createTypeRegistryMock(); typeRegistry.getAllTypes.mockReturnValue([ { name: 'type 1', indexPattern: '.kibana' }, @@ -329,6 +342,12 @@ describe('CoreUsageDataService', () => { "storeSizeBytes": 2000, }, ], + "legacyUrlAliases": Object { + "activeCount": 1, + "disabledCount": 2, + "inactiveCount": 3, + "totalCount": 6, + }, }, }, } diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index afe1b45175f86..7cf38dddc563e 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -13,6 +13,11 @@ import { hasConfigPathIntersection, ChangedDeprecatedPaths } from '@kbn/config'; import { CoreService } from 'src/core/types'; import { Logger, SavedObjectsServiceStart, SavedObjectTypeRegistry } from 'src/core/server'; +import { + AggregationsFiltersAggregate, + AggregationsFiltersBucketItem, + SearchTotalHits, +} from '@elastic/elasticsearch/api/types'; import { CoreContext } from '../core_context'; import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config'; import { HttpConfigType, InternalHttpServiceSetup } from '../http'; @@ -29,6 +34,7 @@ import { isConfigured } from './is_configured'; import { ElasticsearchServiceStart } from '../elasticsearch'; import { KibanaConfigType } from '../kibana_config'; import { coreUsageStatsType } from './core_usage_stats'; +import { LEGACY_URL_ALIAS_TYPE } from '../saved_objects/object_types'; import { CORE_USAGE_STATS_TYPE } from './constants'; import { CoreUsageStatsClient } from './core_usage_stats_client'; import { MetricsServiceSetup, OpsMetrics } from '..'; @@ -98,11 +104,25 @@ export class CoreUsageDataService implements CoreService { - const indices = await Promise.all( + const [indices, legacyUrlAliases] = await Promise.all([ + this.getSavedObjectIndicesUsageData(savedObjects, elasticsearch), + this.getSavedObjectAliasUsageData(elasticsearch), + ]); + return { + indices, + legacyUrlAliases, + }; + } + + private async getSavedObjectIndicesUsageData( + savedObjects: SavedObjectsServiceStart, + elasticsearch: ElasticsearchServiceStart + ) { + return Promise.all( Array.from( savedObjects .getTypeRegistry() @@ -136,10 +156,44 @@ export class CoreUsageDataService implements CoreService; + const disabledCount = buckets.disabled.doc_count as number; + const activeCount = buckets.active.doc_count as number; + const inactiveCount = totalCount - disabledCount - activeCount; + + return { totalCount, disabledCount, activeCount, inactiveCount }; } private async getCoreUsageData( @@ -162,7 +216,7 @@ export class CoreUsageDataService implements CoreService { - test(`does not allow "disableEmbedding" to be set to true`, () => { + it(`does not allow "disableEmbedding" to be set to true`, () => { // This is intentionally not editable in the raw CSP config. // Users should set `server.securityResponseHeaders.disableEmbedding` to control this config property. expect(() => config.schema.validate({ disableEmbedding: true })).toThrowError( '[disableEmbedding]: expected value to equal [false]' ); }); + + describe(`"script_src"`, () => { + it(`throws if containing 'unsafe-inline' when 'strict' is true`, () => { + expect(() => + config.schema.validate({ + strict: true, + warnLegacyBrowsers: false, + script_src: [`'self'`, `unsafe-inline`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.strict\` is true"` + ); + + expect(() => + config.schema.validate({ + strict: true, + warnLegacyBrowsers: false, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.strict\` is true"` + ); + }); + + it(`throws if containing 'unsafe-inline' when 'warnLegacyBrowsers' is true`, () => { + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: true, + script_src: [`'self'`, `unsafe-inline`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.warnLegacyBrowsers\` is true"` + ); + + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: true, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.warnLegacyBrowsers\` is true"` + ); + }); + + it(`does not throw if containing 'unsafe-inline' when 'strict' and 'warnLegacyBrowsers' are false`, () => { + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: false, + script_src: [`'self'`, `unsafe-inline`], + }) + ).not.toThrow(); + + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: false, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).not.toThrow(); + }); + + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + script_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + script_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + script_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + script_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"worker_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + worker_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + worker_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + worker_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + worker_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"style_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + style_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + style_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + style_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + style_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"connect_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + connect_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + connect_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + connect_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + connect_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"default_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + default_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + default_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + default_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + default_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"font_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + font_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + font_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + font_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + font_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"frame_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + frame_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + frame_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + frame_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + frame_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"img_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + img_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + img_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + img_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + img_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"frame_ancestors"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + frame_ancestors: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); }); diff --git a/src/core/server/csp/config.ts b/src/core/server/csp/config.ts index a61fa1b03a45c..3a7cb20985cea 100644 --- a/src/core/server/csp/config.ts +++ b/src/core/server/csp/config.ts @@ -7,28 +7,150 @@ */ import { TypeOf, schema } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '../internal_types'; + +interface DirectiveValidationOptions { + allowNone: boolean; + allowNonce: boolean; +} + +const getDirectiveValidator = (options: DirectiveValidationOptions) => { + const validateValue = getDirectiveValueValidator(options); + return (values: string[]) => { + for (const value of values) { + const error = validateValue(value); + if (error) { + return error; + } + } + }; +}; + +const getDirectiveValueValidator = ({ allowNone, allowNonce }: DirectiveValidationOptions) => { + return (value: string) => { + if (!allowNonce && value.startsWith('nonce-')) { + return `using "nonce-*" is considered insecure and is not allowed`; + } + if (!allowNone && (value === `none` || value === `'none'`)) { + return `using "none" would conflict with Kibana's default csp configuration and is not allowed`; + } + }; +}; + +const configSchema = schema.object( + { + rules: schema.maybe(schema.arrayOf(schema.string())), + script_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + worker_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + style_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + connect_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + default_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + font_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + frame_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + img_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + frame_ancestors: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + report_uri: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: true, allowNonce: false }), + }), + report_to: schema.arrayOf(schema.string(), { + defaultValue: [], + }), + strict: schema.boolean({ defaultValue: true }), + warnLegacyBrowsers: schema.boolean({ defaultValue: true }), + disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }), + }, + { + validate: (cspConfig) => { + if (cspConfig.rules && hasDirectiveSpecified(cspConfig)) { + return `"csp.rules" cannot be used when specifying per-directive additions such as "script_src", "worker_src" or "style_src"`; + } + const hasUnsafeInlineScriptSrc = + cspConfig.script_src.includes(`unsafe-inline`) || + cspConfig.script_src.includes(`'unsafe-inline'`); + + if (cspConfig.strict && hasUnsafeInlineScriptSrc) { + return 'cannot use `unsafe-inline` for `script_src` when `csp.strict` is true'; + } + if (cspConfig.warnLegacyBrowsers && hasUnsafeInlineScriptSrc) { + return 'cannot use `unsafe-inline` for `script_src` when `csp.warnLegacyBrowsers` is true'; + } + }, + } +); + +const hasDirectiveSpecified = (rawConfig: CspConfigType): boolean => { + return Boolean( + rawConfig.script_src.length || + rawConfig.worker_src.length || + rawConfig.style_src.length || + rawConfig.connect_src.length || + rawConfig.default_src.length || + rawConfig.font_src.length || + rawConfig.frame_src.length || + rawConfig.img_src.length || + rawConfig.frame_ancestors.length || + rawConfig.report_uri.length || + rawConfig.report_to.length + ); +}; /** * @internal */ -export type CspConfigType = TypeOf; +export type CspConfigType = TypeOf; -export const config = { +export const config: ServiceConfigDescriptor = { // TODO: Move this to server.csp using config deprecations // ? https://github.com/elastic/kibana/pull/52251 path: 'csp', - schema: schema.object({ - rules: schema.arrayOf(schema.string(), { - defaultValue: [ - `script-src 'unsafe-eval' 'self'`, - `worker-src blob: 'self'`, - `style-src 'unsafe-inline' 'self'`, - ], - }), - strict: schema.boolean({ defaultValue: true }), - warnLegacyBrowsers: schema.boolean({ defaultValue: true }), - disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }), - }), + schema: configSchema, + deprecations: () => [ + (rawConfig, fromPath, addDeprecation) => { + const cspConfig = rawConfig[fromPath]; + if (cspConfig?.rules) { + addDeprecation({ + message: + '`csp.rules` is deprecated in favor of directive specific configuration. Please use `csp.connect_src`, ' + + '`csp.default_src`, `csp.font_src`, `csp.frame_ancestors`, `csp.frame_src`, `csp.img_src`, ' + + '`csp.report_uri`, `csp.report_to`, `csp.script_src`, `csp.style_src`, and `csp.worker_src` instead.', + correctiveActions: { + manualSteps: [ + `Remove "csp.rules" from the Kibana config file."`, + `Add directive specific configurations to the config file using "csp.connect_src", "csp.default_src", "csp.font_src", ` + + `"csp.frame_ancestors", "csp.frame_src", "csp.img_src", "csp.report_uri", "csp.report_to", "csp.script_src", ` + + `"csp.style_src", and "csp.worker_src".`, + ], + }, + }); + } + }, + ], }; - -export const FRAME_ANCESTORS_RULE = `frame-ancestors 'self'`; // only used by CspConfig when embedding is disabled diff --git a/src/core/server/csp/csp_config.test.ts b/src/core/server/csp/csp_config.test.ts index 1e023c6f08ea8..a1bac7d4ae73e 100644 --- a/src/core/server/csp/csp_config.test.ts +++ b/src/core/server/csp/csp_config.test.ts @@ -7,7 +7,7 @@ */ import { CspConfig } from './csp_config'; -import { FRAME_ANCESTORS_RULE } from './config'; +import { config as cspConfig, CspConfigType } from './config'; // CSP rules aren't strictly additive, so any change can potentially expand or // restrict the policy in a way we consider a breaking change. For that reason, @@ -23,6 +23,12 @@ import { FRAME_ANCESTORS_RULE } from './config'; // the nature of a change in defaults during a PR review. describe('CspConfig', () => { + let defaultConfig: CspConfigType; + + beforeEach(() => { + defaultConfig = cspConfig.schema.validate({}); + }); + test('DEFAULT', () => { expect(CspConfig.DEFAULT).toMatchInlineSnapshot(` CspConfig { @@ -40,50 +46,129 @@ describe('CspConfig', () => { }); test('defaults from config', () => { - expect(new CspConfig()).toEqual(CspConfig.DEFAULT); + expect(new CspConfig(defaultConfig)).toEqual(CspConfig.DEFAULT); }); describe('partial config', () => { test('allows "rules" to be set and changes header', () => { - const rules = ['foo', 'bar']; - const config = new CspConfig({ rules }); + const rules = [`foo 'self'`, `bar 'self'`]; + const config = new CspConfig({ ...defaultConfig, rules }); expect(config.rules).toEqual(rules); - expect(config.header).toMatchInlineSnapshot(`"foo; bar"`); + expect(config.header).toMatchInlineSnapshot(`"foo 'self'; bar 'self'"`); }); test('allows "strict" to be set', () => { - const config = new CspConfig({ strict: false }); + const config = new CspConfig({ ...defaultConfig, strict: false }); expect(config.strict).toEqual(false); expect(config.strict).not.toEqual(CspConfig.DEFAULT.strict); }); test('allows "warnLegacyBrowsers" to be set', () => { const warnLegacyBrowsers = false; - const config = new CspConfig({ warnLegacyBrowsers }); + const config = new CspConfig({ ...defaultConfig, warnLegacyBrowsers }); expect(config.warnLegacyBrowsers).toEqual(warnLegacyBrowsers); expect(config.warnLegacyBrowsers).not.toEqual(CspConfig.DEFAULT.warnLegacyBrowsers); }); + test('allows "worker_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + worker_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`worker-src foo bar`]); + expect(config.header).toEqual(`worker-src foo bar`); + }); + + test('allows "style_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + style_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`style-src foo bar`]); + expect(config.header).toEqual(`style-src foo bar`); + }); + + test('allows "script_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + script_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`script-src foo bar`]); + expect(config.header).toEqual(`script-src foo bar`); + }); + + test('allows all directives to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + script_src: ['script', 'foo'], + worker_src: ['worker', 'bar'], + style_src: ['style', 'dolly'], + }); + expect(config.rules).toEqual([ + `script-src script foo`, + `worker-src worker bar`, + `style-src style dolly`, + ]); + expect(config.header).toEqual( + `script-src script foo; worker-src worker bar; style-src style dolly` + ); + }); + + test('applies defaults when `rules` is undefined', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: undefined, + script_src: ['script'], + worker_src: ['worker'], + style_src: ['style'], + }); + expect(config.rules).toEqual([ + `script-src 'unsafe-eval' 'self' script`, + `worker-src blob: 'self' worker`, + `style-src 'unsafe-inline' 'self' style`, + ]); + expect(config.header).toEqual( + `script-src 'unsafe-eval' 'self' script; worker-src blob: 'self' worker; style-src 'unsafe-inline' 'self' style` + ); + }); + describe('allows "disableEmbedding" to be set', () => { const disableEmbedding = true; test('and changes rules/header if custom rules are not defined', () => { - const config = new CspConfig({ disableEmbedding }); + const config = new CspConfig({ ...defaultConfig, disableEmbedding }); expect(config.disableEmbedding).toEqual(disableEmbedding); expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); - expect(config.rules).toEqual(expect.arrayContaining([FRAME_ANCESTORS_RULE])); + expect(config.rules).toEqual(expect.arrayContaining([`frame-ancestors 'self'`])); expect(config.header).toMatchInlineSnapshot( `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'"` ); }); test('and does not change rules/header if custom rules are defined', () => { - const rules = ['foo', 'bar']; - const config = new CspConfig({ disableEmbedding, rules }); + const rules = [`foo 'self'`, `bar 'self'`]; + const config = new CspConfig({ ...defaultConfig, disableEmbedding, rules }); expect(config.disableEmbedding).toEqual(disableEmbedding); expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); expect(config.rules).toEqual(rules); - expect(config.header).toMatchInlineSnapshot(`"foo; bar"`); + expect(config.header).toMatchInlineSnapshot(`"foo 'self'; bar 'self'"`); + }); + + test('and overrides `frame-ancestors` if set', () => { + const config = new CspConfig({ + ...defaultConfig, + disableEmbedding: true, + frame_ancestors: ['foo.com'], + }); + expect(config.disableEmbedding).toEqual(disableEmbedding); + expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); + expect(config.header).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'"` + ); }); }); }); diff --git a/src/core/server/csp/csp_config.ts b/src/core/server/csp/csp_config.ts index 649c81576ef52..13778088d9df2 100644 --- a/src/core/server/csp/csp_config.ts +++ b/src/core/server/csp/csp_config.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { config, FRAME_ANCESTORS_RULE } from './config'; +import { config, CspConfigType } from './config'; +import { CspDirectives } from './csp_directives'; const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); @@ -50,8 +51,9 @@ export interface ICspConfig { * @public */ export class CspConfig implements ICspConfig { - static readonly DEFAULT = new CspConfig(); + static readonly DEFAULT = new CspConfig(DEFAULT_CONFIG); + readonly #directives: CspDirectives; public readonly rules: string[]; public readonly strict: boolean; public readonly warnLegacyBrowsers: boolean; @@ -62,16 +64,18 @@ export class CspConfig implements ICspConfig { * Returns the default CSP configuration when passed with no config * @internal */ - constructor(rawCspConfig: Partial> = {}) { - const source = { ...DEFAULT_CONFIG, ...rawCspConfig }; - - this.rules = [...source.rules]; - this.strict = source.strict; - this.warnLegacyBrowsers = source.warnLegacyBrowsers; - this.disableEmbedding = source.disableEmbedding; - if (!rawCspConfig.rules?.length && source.disableEmbedding) { - this.rules.push(FRAME_ANCESTORS_RULE); + constructor(rawCspConfig: CspConfigType) { + this.#directives = CspDirectives.fromConfig(rawCspConfig); + if (!rawCspConfig.rules?.length && rawCspConfig.disableEmbedding) { + this.#directives.clearDirectiveValues('frame-ancestors'); + this.#directives.addDirectiveValue('frame-ancestors', `'self'`); } - this.header = this.rules.join('; '); + + this.rules = this.#directives.getRules(); + this.header = this.#directives.getCspHeader(); + + this.strict = rawCspConfig.strict; + this.warnLegacyBrowsers = rawCspConfig.warnLegacyBrowsers; + this.disableEmbedding = rawCspConfig.disableEmbedding; } } diff --git a/src/core/server/csp/csp_directives.test.ts b/src/core/server/csp/csp_directives.test.ts new file mode 100644 index 0000000000000..1077b6ea9f3cd --- /dev/null +++ b/src/core/server/csp/csp_directives.test.ts @@ -0,0 +1,266 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CspDirectives } from './csp_directives'; +import { config as cspConfig } from './config'; + +describe('CspDirectives', () => { + describe('#addDirectiveValue', () => { + it('properly updates the rules', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo", + ] + `); + + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + ] + `); + }); + + it('properly updates the header', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo"`); + + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo bar"`); + }); + + it('handles distinct directives', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'bar'); + directives.addDirectiveValue('worker-src', 'dolly'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src foo bar; worker-src dolly"` + ); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + "worker-src dolly", + ] + `); + }); + + it('removes duplicates', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo bar"`); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + ] + `); + }); + + it('automatically adds single quotes for keywords', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'none'); + directives.addDirectiveValue('style-src', 'self'); + directives.addDirectiveValue('style-src', 'strict-dynamic'); + directives.addDirectiveValue('style-src', 'report-sample'); + directives.addDirectiveValue('style-src', 'unsafe-inline'); + directives.addDirectiveValue('style-src', 'unsafe-eval'); + directives.addDirectiveValue('style-src', 'unsafe-hashes'); + directives.addDirectiveValue('style-src', 'unsafe-allow-redirects'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src 'none' 'self' 'strict-dynamic' 'report-sample' 'unsafe-inline' 'unsafe-eval' 'unsafe-hashes' 'unsafe-allow-redirects'"` + ); + }); + + it('does not add single quotes for keywords when already present', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', `'none'`); + directives.addDirectiveValue('style-src', `'self'`); + directives.addDirectiveValue('style-src', `'strict-dynamic'`); + directives.addDirectiveValue('style-src', `'report-sample'`); + directives.addDirectiveValue('style-src', `'unsafe-inline'`); + directives.addDirectiveValue('style-src', `'unsafe-eval'`); + directives.addDirectiveValue('style-src', `'unsafe-hashes'`); + directives.addDirectiveValue('style-src', `'unsafe-allow-redirects'`); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src 'none' 'self' 'strict-dynamic' 'report-sample' 'unsafe-inline' 'unsafe-eval' 'unsafe-hashes' 'unsafe-allow-redirects'"` + ); + }); + }); + + describe('#fromConfig', () => { + it('returns the correct rules for the default config', () => { + const config = cspConfig.schema.validate({}); + const directives = CspDirectives.fromConfig(config); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + ] + `); + }); + + it('returns the correct header for the default config', () => { + const config = cspConfig.schema.validate({}); + const directives = CspDirectives.fromConfig(config); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'"` + ); + }); + + it('handles config with rules', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src 'self' http://foo.com`, `worker-src 'self'`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('adds single quotes for keyword for rules', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src self http://foo.com`, `worker-src self`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('handles multiple whitespaces when parsing rules', () => { + const config = cspConfig.schema.validate({ + rules: [` script-src 'self' http://foo.com `, ` worker-src 'self' `], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('supports unregistered directives', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src 'self' http://foo.com`, `img-src 'self'`, 'foo bar'], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "img-src 'self'", + "foo bar", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; img-src 'self'; foo bar"` + ); + }); + + it('adds default value for config with directives', () => { + const config = cspConfig.schema.validate({ + script_src: [`baz`], + worker_src: [`foo`], + style_src: [`bar`, `dolly`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self' baz", + "worker-src blob: 'self' foo", + "style-src 'unsafe-inline' 'self' bar dolly", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self' baz; worker-src blob: 'self' foo; style-src 'unsafe-inline' 'self' bar dolly"` + ); + }); + + it('adds additional values for some directives without defaults', () => { + const config = cspConfig.schema.validate({ + connect_src: [`connect-src`], + default_src: [`default-src`], + font_src: [`font-src`], + frame_src: [`frame-src`], + img_src: [`img-src`], + frame_ancestors: [`frame-ancestors`], + report_uri: [`report-uri`], + report_to: [`report-to`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + "connect-src 'self' connect-src", + "default-src 'self' default-src", + "font-src 'self' font-src", + "frame-src 'self' frame-src", + "img-src 'self' img-src", + "frame-ancestors 'self' frame-ancestors", + "report-uri report-uri", + "report-to report-to", + ] + `); + }); + + it('adds single quotes for keywords in added directives', () => { + const config = cspConfig.schema.validate({ + script_src: [`unsafe-hashes`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self' 'unsafe-hashes'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self' 'unsafe-hashes'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'"` + ); + }); + }); +}); diff --git a/src/core/server/csp/csp_directives.ts b/src/core/server/csp/csp_directives.ts new file mode 100644 index 0000000000000..9e3b60f7f1e4f --- /dev/null +++ b/src/core/server/csp/csp_directives.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CspConfigType } from './config'; + +export type CspDirectiveName = + | 'script-src' + | 'worker-src' + | 'style-src' + | 'frame-ancestors' + | 'connect-src' + | 'default-src' + | 'font-src' + | 'frame-src' + | 'img-src' + | 'report-uri' + | 'report-to'; + +/** + * The default rules that are always applied + */ +export const defaultRules: Partial> = { + 'script-src': [`'unsafe-eval'`, `'self'`], + 'worker-src': [`blob:`, `'self'`], + 'style-src': [`'unsafe-inline'`, `'self'`], +}; + +/** + * Per-directive rules that will be added when the configuration contains at least one value + * Main purpose is to add `self` value to some directives when the configuration specifies other values + */ +export const additionalRules: Partial> = { + 'connect-src': [`'self'`], + 'default-src': [`'self'`], + 'font-src': [`'self'`], + 'img-src': [`'self'`], + 'frame-ancestors': [`'self'`], + 'frame-src': [`'self'`], +}; + +export class CspDirectives { + private readonly directives = new Map>(); + + addDirectiveValue(directiveName: CspDirectiveName, directiveValue: string) { + if (!this.directives.has(directiveName)) { + this.directives.set(directiveName, new Set()); + } + this.directives.get(directiveName)!.add(normalizeDirectiveValue(directiveValue)); + } + + clearDirectiveValues(directiveName: CspDirectiveName) { + this.directives.delete(directiveName); + } + + getCspHeader() { + return this.getRules().join('; '); + } + + getRules() { + return [...this.directives.entries()].map(([name, values]) => { + return [name, ...values].join(' '); + }); + } + + static fromConfig(config: CspConfigType): CspDirectives { + const cspDirectives = new CspDirectives(); + + // adding `csp.rules` or `default` rules + const initialRules = config.rules ? parseRules(config.rules) : { ...defaultRules }; + Object.entries(initialRules).forEach(([key, values]) => { + values?.forEach((value) => { + cspDirectives.addDirectiveValue(key as CspDirectiveName, value); + }); + }); + + // adding per-directive configuration + const additiveConfig = parseConfigDirectives(config); + [...additiveConfig.entries()].forEach(([directiveName, directiveValues]) => { + const additionalValues = additionalRules[directiveName] ?? []; + [...additionalValues, ...directiveValues].forEach((value) => { + cspDirectives.addDirectiveValue(directiveName, value); + }); + }); + + return cspDirectives; + } +} + +const parseRules = (rules: string[]): Partial> => { + const directives: Partial> = {}; + rules.forEach((rule) => { + const [name, ...values] = rule.replace(/\s+/g, ' ').trim().split(' '); + directives[name as CspDirectiveName] = values; + }); + return directives; +}; + +const parseConfigDirectives = (cspConfig: CspConfigType): Map => { + const map = new Map(); + + if (cspConfig.script_src?.length) { + map.set('script-src', cspConfig.script_src); + } + if (cspConfig.worker_src?.length) { + map.set('worker-src', cspConfig.worker_src); + } + if (cspConfig.style_src?.length) { + map.set('style-src', cspConfig.style_src); + } + if (cspConfig.connect_src?.length) { + map.set('connect-src', cspConfig.connect_src); + } + if (cspConfig.default_src?.length) { + map.set('default-src', cspConfig.default_src); + } + if (cspConfig.font_src?.length) { + map.set('font-src', cspConfig.font_src); + } + if (cspConfig.frame_src?.length) { + map.set('frame-src', cspConfig.frame_src); + } + if (cspConfig.img_src?.length) { + map.set('img-src', cspConfig.img_src); + } + if (cspConfig.frame_ancestors?.length) { + map.set('frame-ancestors', cspConfig.frame_ancestors); + } + if (cspConfig.report_uri?.length) { + map.set('report-uri', cspConfig.report_uri); + } + if (cspConfig.report_to?.length) { + map.set('report-to', cspConfig.report_to); + } + + return map; +}; + +const keywordTokens = [ + 'none', + 'self', + 'strict-dynamic', + 'report-sample', + 'unsafe-inline', + 'unsafe-eval', + 'unsafe-hashes', + 'unsafe-allow-redirects', +]; + +function normalizeDirectiveValue(value: string) { + if (keywordTokens.includes(value)) { + return `'${value}'`; + } + return value; +} diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index c802163866423..55af02a08561b 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -69,7 +69,11 @@ configService.atPath.mockImplementation((path) => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 56095336d970b..06a4745632233 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -8,7 +8,7 @@ import uuid from 'uuid'; import { config, HttpConfig } from './http_config'; -import { CspConfig } from '../csp'; +import { config as cspConfig } from '../csp'; import { ExternalUrlConfig } from '../external_url'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost', '0.0.0.0']; @@ -459,7 +459,8 @@ describe('HttpConfig', () => { }, }, }); - const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT, ExternalUrlConfig.DEFAULT); + const rawCspConfig = cspConfig.schema.validate({}); + const httpConfig = new HttpConfig(rawConfig, rawCspConfig, ExternalUrlConfig.DEFAULT); expect(httpConfig.customResponseHeaders).toEqual({ string: 'string', diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 7624a11a6f03f..ffbd91c645382 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -7,9 +7,10 @@ */ import { Server } from 'http'; -import { readFileSync } from 'fs'; +import { rmdir, mkdtemp, readFile, writeFile } from 'fs/promises'; import supertest from 'supertest'; import { omit } from 'lodash'; +import { join } from 'path'; import { ByteSizeValue, schema } from '@kbn/config-schema'; import { HttpConfig } from './http_config'; @@ -47,9 +48,9 @@ const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); let certificate: string; let key: string; -beforeAll(() => { - certificate = readFileSync(KBN_CERT_PATH, 'utf8'); - key = readFileSync(KBN_KEY_PATH, 'utf8'); +beforeAll(async () => { + certificate = await readFile(KBN_CERT_PATH, 'utf8'); + key = await readFile(KBN_KEY_PATH, 'utf8'); }); beforeEach(() => { @@ -1409,6 +1410,19 @@ describe('setup contract', () => { }); describe('#registerStaticDir', () => { + const assetFolder = join(__dirname, 'integration_tests', 'fixtures', 'static'); + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdtemp('cache-test'); + }); + + afterAll(async () => { + if (tempDir) { + await rmdir(tempDir, { recursive: true }); + } + }); + test('does not throw if called after stop', async () => { const { registerStaticDir } = await server.setup(config); await server.stop(); @@ -1416,6 +1430,111 @@ describe('setup contract', () => { registerStaticDir('/path1/{path*}', '/path/to/resource'); }).not.toThrow(); }); + + test('returns correct headers for static assets', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + }); + + test('returns compressed version if present', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/compression_available.json') + .set('accept-encoding', 'gzip') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + expect(response.get('content-encoding')).toEqual('gzip'); + }); + + test('returns uncompressed version if compressed asset is not available', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('accept-encoding', 'gzip') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + expect(response.get('content-encoding')).toBeUndefined(); + }); + + test('returns a 304 if etag value matches', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .expect(200); + + const etag = response.get('etag'); + expect(etag).not.toBeUndefined(); + + await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('If-None-Match', etag) + .expect(304); + }); + + test('serves content if etag values does not match', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + + await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('If-None-Match', `"definitely not a valid etag"`) + .expect(200); + }); + + test('dynamically updates depending on the content of the file', async () => { + const tempFile = join(tempDir, 'some_file.json'); + + const { registerStaticDir, server: innerServer } = await server.setup(config); + registerStaticDir('/static/{path*}', tempDir); + + await server.start(); + + await supertest(innerServer.listener).get('/static/some_file.json').expect(404); + + await writeFile(tempFile, `{ "over": 9000 }`); + + let response = await supertest(innerServer.listener) + .get('/static/some_file.json') + .expect(200); + + const etag1 = response.get('etag'); + + await writeFile(tempFile, `{ "over": 42 }`); + + response = await supertest(innerServer.listener).get('/static/some_file.json').expect(200); + + const etag2 = response.get('etag'); + + expect(etag1).not.toEqual(etag2); + }); }); describe('#registerOnPreRouting', () => { diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 8b4c3b9416152..d43d86d587d06 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -465,7 +465,13 @@ export class HttpServer { lookupCompressed: true, }, }, - options: { auth: false }, + options: { + auth: false, + cache: { + privacy: 'public', + otherwise: 'must-revalidate', + }, + }, }); } diff --git a/src/core/server/http/integration_tests/fixtures/static/compression_available.json b/src/core/server/http/integration_tests/fixtures/static/compression_available.json new file mode 100644 index 0000000000000..1f878fb465cff --- /dev/null +++ b/src/core/server/http/integration_tests/fixtures/static/compression_available.json @@ -0,0 +1,3 @@ +{ + "hello": "dolly" +} diff --git a/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz b/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz new file mode 100644 index 0000000000000..e77819d2e8e59 Binary files /dev/null and b/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz differ diff --git a/src/core/server/http/integration_tests/fixtures/static/some_json.json b/src/core/server/http/integration_tests/fixtures/static/some_json.json new file mode 100644 index 0000000000000..c8c4105eb57cd --- /dev/null +++ b/src/core/server/http/integration_tests/fixtures/static/some_json.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index cbd300fdc9c09..c2023c5577d61 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -79,7 +79,11 @@ describe('core lifecycle handlers', () => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index 7571184363d2e..dfc47098724cc 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -163,24 +163,24 @@ describe('KibanaRequest', () => { describe('events', () => { describe('aborted$', () => { - it('emits once and completes when request aborted', async (done) => { + it('emits once and completes when request aborted', async () => { expect.assertions(1); const { server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); const nextSpy = jest.fn(); - router.get({ path: '/', validate: false }, async (context, request, res) => { - request.events.aborted$.subscribe({ - next: nextSpy, - complete: () => { - expect(nextSpy).toHaveBeenCalledTimes(1); - done(); - }, - }); - // prevents the server to respond - await delay(30000); - return res.ok({ body: 'ok' }); + const done = new Promise((resolve) => { + router.get({ path: '/', validate: false }, async (context, request, res) => { + request.events.aborted$.subscribe({ + next: nextSpy, + complete: resolve, + }); + + // prevents the server to respond + await delay(30000); + return res.ok({ body: 'ok' }); + }); }); await server.start(); @@ -191,6 +191,8 @@ describe('KibanaRequest', () => { .end(); setTimeout(() => incomingRequest.abort(), 50); + await done; + expect(nextSpy).toHaveBeenCalledTimes(1); }); it('completes & does not emit when request handled', async () => { @@ -299,25 +301,24 @@ describe('KibanaRequest', () => { expect(completeSpy).toHaveBeenCalledTimes(1); }); - it('emits once and completes when response is aborted', async (done) => { + it('emits once and completes when response is aborted', async () => { expect.assertions(2); const { server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); const nextSpy = jest.fn(); - router.get({ path: '/', validate: false }, async (context, req, res) => { - req.events.completed$.subscribe({ - next: nextSpy, - complete: () => { - expect(nextSpy).toHaveBeenCalledTimes(1); - done(); - }, - }); + const done = new Promise((resolve) => { + router.get({ path: '/', validate: false }, async (context, req, res) => { + req.events.completed$.subscribe({ + next: nextSpy, + complete: resolve, + }); - expect(nextSpy).not.toHaveBeenCalled(); - await delay(30000); - return res.ok({ body: 'ok' }); + expect(nextSpy).not.toHaveBeenCalled(); + await delay(30000); + return res.ok({ body: 'ok' }); + }); }); await server.start(); @@ -327,6 +328,8 @@ describe('KibanaRequest', () => { // end required to send request .end(); setTimeout(() => incomingRequest.abort(), 50); + await done; + expect(nextSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index b3180b43d0026..4e1a88e967f8f 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -56,7 +56,11 @@ configService.atPath.mockImplementation((path) => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_5k_so_node_01.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_5k_so_node_01.zip new file mode 100644 index 0000000000000..70966debbaf0e Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_5k_so_node_01.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_5k_so_node_02.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_5k_so_node_02.zip new file mode 100644 index 0000000000000..451c48d8107c8 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_5k_so_node_02.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index f4e0dd8fffcab..4c9e37d17f2e7 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -95,6 +95,12 @@ describe('migration v2', () => { }, ], }, + // reporting loads headless browser, that prevents nodejs process from exiting. + xpack: { + reporting: { + enabled: false, + }, + }, }, { oss, diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/multiple_es_nodes.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/multiple_es_nodes.test.ts new file mode 100644 index 0000000000000..89ee9a2d4de97 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/multiple_es_nodes.test.ts @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; +import glob from 'glob'; +import { kibanaServerTestUser } from '@kbn/test'; +import { kibanaPackageJson as pkg } from '@kbn/utils'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import type { ElasticsearchClient } from '../../../elasticsearch'; +import { Root } from '../../../root'; + +const LOG_FILE_PREFIX = 'migration_test_multiple_es_nodes'; + +const asyncUnlink = Util.promisify(Fs.unlink); + +async function removeLogFile() { + glob(Path.join(__dirname, `${LOG_FILE_PREFIX}_*.log`), (err, files) => { + files.forEach(async (file) => { + // ignore errors if it doesn't exist + await asyncUnlink(file).catch(() => void 0); + }); + }); +} + +function extractSortNumberFromId(id: string): number { + const parsedId = parseInt(id.split(':')[1], 10); // "foo:123" -> 123 + if (isNaN(parsedId)) { + throw new Error(`Failed to parse Saved Object ID [${id}]. Result is NaN`); + } + return parsedId; +} + +async function fetchDocs(esClient: ElasticsearchClient, index: string, type: string) { + const { body } = await esClient.search({ + index, + size: 10000, + body: { + query: { + bool: { + should: [ + { + term: { type }, + }, + ], + }, + }, + }, + }); + + return body.hits.hits + .map((h) => ({ + ...h._source, + id: h._id, + })) + .sort((a, b) => extractSortNumberFromId(a.id) - extractSortNumberFromId(b.id)); +} + +interface RootConfig { + logFileName: string; + hosts: string[]; +} + +function createRoot({ logFileName, hosts }: RootConfig) { + return kbnTestServer.createRoot({ + elasticsearch: { + hosts, + username: kibanaServerTestUser.username, + password: kibanaServerTestUser.password, + }, + migrations: { + skip: false, + enableV2: true, + batchSize: 100, // fixture contains 5000 docs + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFileName, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }); +} + +describe('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + const migratedIndex = `.kibana_${pkg.version}_001`; + + beforeAll(async () => { + await removeLogFile(); + }); + + afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + afterEach(async () => { + if (root) { + await root.shutdown(); + } + + if (esServer) { + await esServer.stop(); + } + }); + + it('migrates saved objects normally with multiple ES nodes', async () => { + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'basic', + clusterName: 'es-test-cluster', + nodes: [ + { + name: 'node-01', + // original SO (5000 total; 2500 of type `foo` + 2500 of type `bar`): + // [ + // { id: 'foo:1', type: 'foo', foo: { status: 'not_migrated_1' } }, + // { id: 'bar:1', type: 'bar', bar: { status: 'not_migrated_1' } }, + // { id: 'foo:2', type: 'foo', foo: { status: 'not_migrated_2' } }, + // { id: 'bar:2', type: 'bar', bar: { status: 'not_migrated_2' } }, + // ]; + dataArchive: Path.join(__dirname, 'archives', '7.13.0_5k_so_node_01.zip'), + }, + { + name: 'node-02', + dataArchive: Path.join(__dirname, 'archives', '7.13.0_5k_so_node_02.zip'), + }, + ], + }, + }, + }); + + esServer = await startES(); + + root = createRoot({ + logFileName: Path.join(__dirname, `${LOG_FILE_PREFIX}.log`), + hosts: esServer.hosts, + }); + + const setup = await root.setup(); + setup.savedObjects.registerType({ + name: 'foo', + hidden: false, + mappings: { properties: { status: { type: 'text' } } }, + namespaceType: 'agnostic', + migrations: { + '7.14.0': (doc) => { + if (doc.attributes?.status) { + doc.attributes.status = doc.attributes.status.replace('not_migrated', 'migrated'); + } + return doc; + }, + }, + }); + setup.savedObjects.registerType({ + name: 'bar', + hidden: false, + mappings: { properties: { status: { type: 'text' } } }, + namespaceType: 'agnostic', + migrations: { + '7.14.0': (doc) => { + if (doc.attributes?.status) { + doc.attributes.status = doc.attributes.status.replace('not_migrated', 'migrated'); + } + return doc; + }, + }, + }); + + await root.start(); + const esClient = esServer.es.getClient(); + + const migratedFooDocs = await fetchDocs(esClient, migratedIndex, 'foo'); + expect(migratedFooDocs.length).toBe(2500); + migratedFooDocs.forEach((doc, i) => { + expect(doc.id).toBe(`foo:${i}`); + expect(doc.foo.status).toBe(`migrated_${i}`); + expect(doc.migrationVersion.foo).toBe('7.14.0'); + }); + + const migratedBarDocs = await fetchDocs(esClient, migratedIndex, 'bar'); + expect(migratedBarDocs.length).toBe(2500); + migratedBarDocs.forEach((doc, i) => { + expect(doc.id).toBe(`bar:${i}`); + expect(doc.bar.status).toBe(`migrated_${i}`); + expect(doc.migrationVersion.bar).toBe('7.14.0'); + }); + }); +}); diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts index 2b5f49123b2cf..6ef4f79ef77c9 100644 --- a/src/core/server/saved_objects/object_types/registration.ts +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -17,6 +17,7 @@ const legacyUrlAliasType: SavedObjectsType = { properties: { sourceId: { type: 'keyword' }, targetType: { type: 'keyword' }, + resolveCounter: { type: 'long' }, disabled: { type: 'boolean' }, // other properties exist, but we aren't querying or aggregating on those, so we don't need to specify them (because we use `dynamic: false` above) }, diff --git a/src/core/server/saved_objects/routes/utils.test.ts b/src/core/server/saved_objects/routes/utils.test.ts index 623d2dcc71fac..2127352e4c60e 100644 --- a/src/core/server/saved_objects/routes/utils.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -101,6 +101,22 @@ describe('createSavedObjectsStreamFromNdJson', () => { }, ]); }); + + it('handles an ndjson stream that only contains excluded saved objects', async () => { + const savedObjectsStream = await createSavedObjectsStreamFromNdJson( + new Readable({ + read() { + this.push( + '{"excludedObjects":[{"id":"foo","reason":"excluded","type":"foo-type"}],"excludedObjectsCount":1,"exportedCount":0,"missingRefCount":0,"missingReferences":[]}\n' + ); + this.push(null); + }, + }) + ); + + const result = await readStreamToCompletion(savedObjectsStream); + expect(result).toEqual([]); + }); }); describe('validateTypes', () => { diff --git a/src/core/server/saved_objects/routes/utils.ts b/src/core/server/saved_objects/routes/utils.ts index e933badfe80fe..47996847a8387 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -32,7 +32,7 @@ export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) } }), createFilterStream( - (obj) => !!obj && !(obj as SavedObjectsExportResultDetails).exportedCount + (obj) => !!obj && (obj as SavedObjectsExportResultDetails).exportedCount === undefined ), createConcatStream([]), ]); diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 22c40a547f419..27228361aef22 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -13,6 +13,7 @@ import { mockUpdateObjectsSpaces, } from './repository.test.mock'; +import { CORE_USAGE_STATS_TYPE, REPOSITORY_RESOLVE_OUTCOME_STATS } from '../../../core_usage_data'; import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; @@ -525,15 +526,22 @@ describe('SavedObjectsRepository', () => { const ns2 = 'bar-namespace'; const ns3 = 'baz-namespace'; const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2] }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns3] }, + { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, ]; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const body = [ - expect.any(Object), + { index: expect.objectContaining({ _id: `${ns2}:dashboard:${obj1.id}` }) }, + expect.objectContaining({ namespace: ns2 }), + { + index: expect.objectContaining({ + _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj1.id}`, + }), + }, expect.objectContaining({ namespaces: [ns2] }), - expect.any(Object), - expect.objectContaining({ namespaces: [ns3] }), + { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj1.id}` }) }, + expect.objectContaining({ namespaces: [ns2, ns3] }), ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), @@ -649,24 +657,19 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-shareable object`, async () => { - const test = async (objType) => { - const obj = { ...obj3, type: objType, initialNamespaces: [] }; - await bulkCreateError( + it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { + const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( obj, - undefined, - expectErrorResult( - obj, - createBadRequestError('"initialNamespaces" can only be used on multi-namespace types') - ) - ); - }; - await test('dashboard'); - await test(NAMESPACE_AGNOSTIC_TYPE); - await test(MULTI_NAMESPACE_ISOLATED_TYPE); + createBadRequestError('"initialNamespaces" cannot be used on space-agnostic types') + ) + ); }); - it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { + it(`returns error when initialNamespaces is empty`, async () => { const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; await bulkCreateError( obj, @@ -678,6 +681,26 @@ describe('SavedObjectsRepository', () => { ); }); + it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType, initialNamespaces) => { + const obj = { ...obj3, type: objType, initialNamespaces }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + it(`returns error when type is invalid`, async () => { const obj = { ...obj3, type: 'unknownType' }; await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); @@ -1865,12 +1888,46 @@ describe('SavedObjectsRepository', () => { }); it(`adds initialNamespaces instead of namespace`, async () => { - const options = { id, namespace, initialNamespaces: ['bar-namespace', 'baz-namespace'] }; - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, options); - expect(client.create).toHaveBeenCalledWith( + const ns2 = 'bar-namespace'; + const ns3 = 'baz-namespace'; + await savedObjectsRepository.create('dashboard', attributes, { + id, + namespace, + initialNamespaces: [ns2], + }); + await savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { + id, + namespace, + initialNamespaces: [ns2], + }); + await savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + id, + namespace, + initialNamespaces: [ns2, ns3], + }); + + expect(client.create).toHaveBeenCalledTimes(3); + expect(client.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: `${ns2}:dashboard:${id}`, + body: expect.objectContaining({ namespace: ns2 }), + }), + expect.anything() + ); + expect(client.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, + body: expect.objectContaining({ namespaces: [ns2] }), + }), + expect.anything() + ); + expect(client.create).toHaveBeenNthCalledWith( + 3, expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}`, - body: expect.objectContaining({ namespaces: options.initialNamespaces }), + body: expect.objectContaining({ namespaces: [ns2, ns3] }), }), expect.anything() ); @@ -1892,29 +1949,40 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-shareable object`, async () => { - const test = async (objType) => { - await expect( - savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) - ).rejects.toThrowError( - createBadRequestError( - '"options.initialNamespaces" can only be used on multi-namespace types' - ) - ); - }; - await test('dashboard'); - await test(MULTI_NAMESPACE_ISOLATED_TYPE); - await test(NAMESPACE_AGNOSTIC_TYPE); + it(`throws when options.initialNamespaces is used with a space-agnostic object`, async () => { + await expect( + savedObjectsRepository.create(NAMESPACE_AGNOSTIC_TYPE, attributes, { + initialNamespaces: [namespace], + }) + ).rejects.toThrowError( + createBadRequestError('"initialNamespaces" cannot be used on space-agnostic types') + ); }); - it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { + it(`throws when options.initialNamespaces is empty`, async () => { await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( - createBadRequestError('"options.initialNamespaces" must be a non-empty array of strings') + createBadRequestError('"initialNamespaces" must be a non-empty array of strings') ); }); + it(`throws when options.initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType, initialNamespaces) => { + await expect( + savedObjectsRepository.create(objType, attributes, { initialNamespaces }) + ).rejects.toThrowError( + createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + it(`throws when options.namespace is '*'`, async () => { await expect( savedObjectsRepository.create(type, attributes, { namespace: ALL_NAMESPACES_STRING }) @@ -3205,6 +3273,24 @@ describe('SavedObjectsRepository', () => { }, }); + /** Each time resolve is called, usage stats are incremented depending upon the outcome. */ + const expectIncrementCounter = (n, outcomeStatString) => { + expect(client.update).toHaveBeenNthCalledWith( + n, + expect.objectContaining({ + body: expect.objectContaining({ + upsert: expect.objectContaining({ + [CORE_USAGE_STATS_TYPE]: { + [outcomeStatString]: 1, + [REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL]: 1, + }, + }), + }), + }), + expect.anything() + ); + }; + describe('outcomes', () => { describe('error', () => { const expectNotFoundError = async (type, id, options) => { @@ -3235,9 +3321,10 @@ describe('SavedObjectsRepository', () => { ); await expectNotFoundError(type, id, options); - expect(client.update).not.toHaveBeenCalled(); + expect(client.update).toHaveBeenCalledTimes(1); // incremented stats expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target expect(client.mget).not.toHaveBeenCalled(); + expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); }); it('because actual object and alias object are both not found', async () => { @@ -3253,9 +3340,10 @@ describe('SavedObjectsRepository', () => { ); await expectNotFoundError(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).not.toHaveBeenCalled(); expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); }); }); @@ -3268,9 +3356,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).not.toHaveBeenCalled(); + expect(client.update).toHaveBeenCalledTimes(1); // incremented stats expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target expect(client.mget).not.toHaveBeenCalled(); + expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'exactMatch', @@ -3287,9 +3376,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target expect(client.mget).not.toHaveBeenCalled(); + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'exactMatch', @@ -3321,9 +3411,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).not.toHaveBeenCalled(); expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'exactMatch', @@ -3362,9 +3453,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).not.toHaveBeenCalled(); expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id: aliasTargetId }), outcome: 'aliasMatch', @@ -3403,9 +3495,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).not.toHaveBeenCalled(); expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'conflict', diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 1577f773434b9..986467c917dd2 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -8,6 +8,11 @@ import { omit, isObject } from 'lodash'; import type { estypes } from '@elastic/elasticsearch'; +import { + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + REPOSITORY_RESOLVE_OUTCOME_STATS, +} from '../../../core_usage_data'; import type { ElasticsearchClient } from '../../../elasticsearch/'; import type { Logger } from '../../../logging'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; @@ -283,28 +288,18 @@ export class SavedObjectsRepository { } = options; const namespace = normalizeNamespace(options.namespace); - if (initialNamespaces) { - if (!this._registry.isShareable(type)) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"options.initialNamespaces" can only be used on multi-namespace types' - ); - } else if (!initialNamespaces.length) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"options.initialNamespaces" must be a non-empty array of strings' - ); - } - } + this.validateInitialNamespaces(type, initialNamespaces); if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } const time = this._getCurrentTime(); - let savedObjectNamespace; + let savedObjectNamespace: string | undefined; let savedObjectNamespaces: string[] | undefined; - if (this._registry.isSingleNamespace(type) && namespace) { - savedObjectNamespace = namespace; + if (this._registry.isSingleNamespace(type)) { + savedObjectNamespace = initialNamespaces ? initialNamespaces[0] : namespace; } else if (this._registry.isMultiNamespace(type)) { if (id && overwrite) { // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces @@ -369,32 +364,29 @@ export class SavedObjectsRepository { let bulkGetRequestIndexCounter = 0; const expectedResults: Either[] = objects.map((object) => { + const { type, id, initialNamespaces } = object; let error: DecoratedError | undefined; - if (!this._allowedTypes.includes(object.type)) { - error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); - } else if (object.initialNamespaces) { - if (!this._registry.isShareable(object.type)) { - error = SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" can only be used on multi-namespace types' - ); - } else if (!object.initialNamespaces.length) { - error = SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" must be a non-empty array of strings' - ); + if (!this._allowedTypes.includes(type)) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } else { + try { + this.validateInitialNamespaces(type, initialNamespaces); + } catch (e) { + error = e; } } if (error) { return { tag: 'Left' as 'Left', - error: { id: object.id, type: object.type, error: errorContent(error) }, + error: { id, type, error: errorContent(error) }, }; } - const method = object.id && overwrite ? 'index' : 'create'; - const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); + const method = id && overwrite ? 'index' : 'create'; + const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type); - if (object.id == null) { + if (id == null) { object.id = SavedObjectsUtils.generateId(); } @@ -434,8 +426,8 @@ export class SavedObjectsRepository { return expectedBulkGetResult; } - let savedObjectNamespace; - let savedObjectNamespaces; + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; let versionProperties; const { esRequestIndex, @@ -469,7 +461,7 @@ export class SavedObjectsRepository { versionProperties = getExpectedVersionProperties(version, actualResult); } else { if (this._registry.isSingleNamespace(object.type)) { - savedObjectNamespace = namespace; + savedObjectNamespace = initialNamespaces ? initialNamespaces[0] : namespace; } else if (this._registry.isMultiNamespace(object.type)) { savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); } @@ -1070,7 +1062,7 @@ export class SavedObjectsRepository { const time = this._getCurrentTime(); // retrieve the alias, and if it is not disabled, update it - const aliasResponse = await this.client.update<{ 'legacy-url-alias': LegacyUrlAlias }>( + const aliasResponse = await this.client.update<{ [LEGACY_URL_ALIAS_TYPE]: LegacyUrlAlias }>( { id: rawAliasId, index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE), @@ -1141,21 +1133,25 @@ export class SavedObjectsRepository { // @ts-expect-error MultiGetHit._source is optional aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace); + let result: SavedObjectsResolveResponse | null = null; + let outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND; if (foundExactMatch && foundAliasMatch) { - return { + result = { // @ts-expect-error MultiGetHit._source is optional saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc), outcome: 'conflict', aliasTargetId: legacyUrlAlias.targetId, }; + outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT; } else if (foundExactMatch) { - return { + result = { // @ts-expect-error MultiGetHit._source is optional saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc), outcome: 'exactMatch', }; + outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH; } else if (foundAliasMatch) { - return { + result = { saved_object: getSavedObjectFromSource( this._registry, type, @@ -1166,6 +1162,13 @@ export class SavedObjectsRepository { outcome: 'aliasMatch', aliasTargetId: legacyUrlAlias.targetId, }; + outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH; + } + + await this.incrementResolveOutcomeStats(outcomeStatString); + + if (result !== null) { + return result; } throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -1662,8 +1665,8 @@ export class SavedObjectsRepository { type: string, id: string, counterFields: Array, - options: SavedObjectsIncrementCounterOptions = {} - ): Promise> { + options?: SavedObjectsIncrementCounterOptions + ) { if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); } @@ -1684,6 +1687,16 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } + return this.incrementCounterInternal(type, id, counterFields, options); + } + + /** @internal incrementCounter function that is used interally and bypasses validation checks. */ + private async incrementCounterInternal( + type: string, + id: string, + counterFields: Array, + options: SavedObjectsIncrementCounterOptions = {} + ): Promise> { const { migrationVersion, refresh = DEFAULT_REFRESH_SETTING, @@ -2077,8 +2090,48 @@ export class SavedObjectsRepository { id: string, options: SavedObjectsBaseOptions ): Promise> { - const object = await this.get(type, id, options); - return { saved_object: object, outcome: 'exactMatch' }; + try { + const object = await this.get(type, id, options); + await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); + return { saved_object: object, outcome: 'exactMatch' }; + } catch (err) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); + } + throw err; + } + } + + private async incrementResolveOutcomeStats(outcomeStatString: string) { + await this.incrementCounterInternal( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [outcomeStatString, REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL], + { refresh: false } + ).catch(() => {}); // if the call fails for some reason, intentionally swallow the error + } + + private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) { + if (!initialNamespaces) { + return; + } + + if (this._registry.isNamespaceAgnostic(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" cannot be used on space-agnostic types' + ); + } else if (!initialNamespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" must be a non-empty array of strings' + ); + } else if ( + !this._registry.isShareable(type) && + (initialNamespaces.length > 1 || initialNamespaces.includes(ALL_NAMESPACES_STRING)) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ); + } } } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index af682cfb81296..abb86d8120a9b 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -63,7 +63,11 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in * {@link SavedObjectsCreateOptions}. * - * Note: this can only be used for multi-namespace object types. + * * For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, + * including the "All spaces" identifier (`'*'`). + * * For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only + * be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. + * * For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. */ initialNamespaces?: string[]; } @@ -96,7 +100,11 @@ export interface SavedObjectsBulkCreateObject { * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in * {@link SavedObjectsCreateOptions}. * - * Note: this can only be used for multi-namespace object types. + * * For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, + * including the "All spaces" identifier (`'*'`). + * * For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only + * be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. + * * For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. */ initialNamespaces?: string[]; } @@ -303,6 +311,9 @@ export interface SavedObjectsUpdateResponse * @public */ export interface SavedObjectsResolveResponse { + /** + * The saved object that was found. + */ saved_object: SavedObject; /** * The outcome for a successful `resolve` call is one of the following values: diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 9e7721fde90e7..13ec594df9075 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -503,6 +503,12 @@ export interface CoreServicesUsageData { storeSizeBytes: number; primaryStoreSizeBytes: number; }[]; + legacyUrlAliases: { + activeCount: number; + inactiveCount: number; + disabledCount: number; + totalCount: number; + }; }; } @@ -765,6 +771,16 @@ export interface CoreUsageStats { 'apiCalls.savedObjectsUpdate.namespace.default.total'?: number; // (undocumented) 'apiCalls.savedObjectsUpdate.total'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.aliasMatch'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.conflict'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.exactMatch'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.notFound'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.total'?: number; } // @public (undocumented) @@ -777,8 +793,12 @@ export interface CountResponse { // @public export class CspConfig implements ICspConfig { + // (undocumented) + #private; + // Warning: (ae-forgotten-export) The symbol "CspConfigType" needs to be exported by the entry point index.d.ts + // // @internal - constructor(rawCspConfig?: Partial>); + constructor(rawCspConfig: CspConfigType); // (undocumented) static readonly DEFAULT: CspConfig; // (undocumented) @@ -2901,7 +2921,7 @@ export class SavedObjectsRepository { resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; updateObjectsSpaces(objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], options?: SavedObjectsUpdateObjectsSpacesOptions): Promise; -} + } // @public export interface SavedObjectsRepositoryFactory { @@ -2921,7 +2941,6 @@ export interface SavedObjectsResolveImportErrorsOptions { export interface SavedObjectsResolveResponse { aliasTargetId?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; - // (undocumented) saved_object: SavedObject; } diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 534d7df9d9466..e1986c5bf1d92 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -114,6 +114,7 @@ test('runs services on "start"', async () => { expect(mockSavedObjectsService.start).not.toHaveBeenCalled(); expect(mockUiSettingsService.start).not.toHaveBeenCalled(); expect(mockMetricsService.start).not.toHaveBeenCalled(); + expect(mockStatusService.start).not.toHaveBeenCalled(); await server.start(); @@ -121,6 +122,7 @@ test('runs services on "start"', async () => { expect(mockSavedObjectsService.start).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1); expect(mockMetricsService.start).toHaveBeenCalledTimes(1); + expect(mockStatusService.start).toHaveBeenCalledTimes(1); }); test('does not fail on "setup" if there are unused paths detected', async () => { diff --git a/src/core/server/server.ts b/src/core/server/server.ts index adf794c390338..3f553dd90678e 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -248,6 +248,7 @@ export class Server { savedObjects: savedObjectsStart, exposedConfigsToUsage: this.plugins.getExposedPluginConfigsToUsage(), }); + this.status.start(); this.coreStart = { capabilities: capabilitiesStart, @@ -261,7 +262,6 @@ export class Server { await this.plugins.start(this.coreStart); - this.status.start(); await this.http.start(); startTransaction?.end(); diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts index b0d9e47876940..9dc1ddcddca3e 100644 --- a/src/core/server/status/plugins_status.test.ts +++ b/src/core/server/status/plugins_status.test.ts @@ -8,7 +8,7 @@ import { PluginName } from '../plugins'; import { PluginsStatusService } from './plugins_status'; -import { of, Observable, BehaviorSubject } from 'rxjs'; +import { of, Observable, BehaviorSubject, ReplaySubject } from 'rxjs'; import { ServiceStatusLevels, CoreStatus, ServiceStatus } from './types'; import { first } from 'rxjs/operators'; import { ServiceStatusLevelSnapshotSerializer } from './test_utils'; @@ -34,6 +34,28 @@ describe('PluginStatusService', () => { ['c', ['a', 'b']], ]); + describe('set', () => { + it('throws an exception if called after registrations are blocked', () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies, + }); + + service.blockNewRegistrations(); + expect(() => { + service.set( + 'a', + of({ + level: ServiceStatusLevels.available, + summary: 'fail!', + }) + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Custom statuses cannot be registered after setup, plugin [a] attempted"` + ); + }); + }); + describe('getDerivedStatus$', () => { it(`defaults to core's most severe status`, async () => { const serviceAvailable = new PluginsStatusService({ @@ -231,6 +253,75 @@ describe('PluginStatusService', () => { { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, ]); }); + + it('updates when a plugin status observable emits', async () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies: new Map([['a', []]]), + }); + const statusUpdates: Array> = []; + const subscription = service + .getAll$() + .subscribe((pluginStatuses) => statusUpdates.push(pluginStatuses)); + + const aStatus$ = new BehaviorSubject({ + level: ServiceStatusLevels.degraded, + summary: 'a degraded', + }); + service.set('a', aStatus$); + aStatus$.next({ level: ServiceStatusLevels.unavailable, summary: 'a unavailable' }); + aStatus$.next({ level: ServiceStatusLevels.available, summary: 'a available' }); + subscription.unsubscribe(); + + expect(statusUpdates).toEqual([ + { a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' } }, + { a: { level: ServiceStatusLevels.degraded, summary: 'a degraded' } }, + { a: { level: ServiceStatusLevels.unavailable, summary: 'a unavailable' } }, + { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, + ]); + }); + + it('emits an unavailable status if first emission times out, then continues future emissions', async () => { + jest.useFakeTimers(); + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies: new Map([ + ['a', []], + ['b', ['a']], + ]), + }); + + const pluginA$ = new ReplaySubject(1); + service.set('a', pluginA$); + const firstEmission = service.getAll$().pipe(first()).toPromise(); + jest.runAllTimers(); + + expect(await firstEmission).toEqual({ + a: { level: ServiceStatusLevels.unavailable, summary: 'Status check timed out after 30s' }, + b: { + level: ServiceStatusLevels.unavailable, + summary: '[a]: Status check timed out after 30s', + detail: 'See the status page for more information', + meta: { + affectedServices: { + a: { + level: ServiceStatusLevels.unavailable, + summary: 'Status check timed out after 30s', + }, + }, + }, + }, + }); + + pluginA$.next({ level: ServiceStatusLevels.available, summary: 'a available' }); + const secondEmission = service.getAll$().pipe(first()).toPromise(); + jest.runAllTimers(); + expect(await secondEmission).toEqual({ + a: { level: ServiceStatusLevels.available, summary: 'a available' }, + b: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + }); + jest.useRealTimers(); + }); }); describe('getDependenciesStatus$', () => { diff --git a/src/core/server/status/plugins_status.ts b/src/core/server/status/plugins_status.ts index 1aacbf3be56db..6a8ef1081e165 100644 --- a/src/core/server/status/plugins_status.ts +++ b/src/core/server/status/plugins_status.ts @@ -7,13 +7,22 @@ */ import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs'; -import { map, distinctUntilChanged, switchMap, debounceTime } from 'rxjs/operators'; +import { + map, + distinctUntilChanged, + switchMap, + debounceTime, + timeoutWith, + startWith, +} from 'rxjs/operators'; import { isDeepStrictEqual } from 'util'; import { PluginName } from '../plugins'; -import { ServiceStatus, CoreStatus } from './types'; +import { ServiceStatus, CoreStatus, ServiceStatusLevels } from './types'; import { getSummaryStatus } from './get_summary_status'; +const STATUS_TIMEOUT_MS = 30 * 1000; // 30 seconds + interface Deps { core$: Observable; pluginDependencies: ReadonlyMap; @@ -23,6 +32,7 @@ export class PluginsStatusService { private readonly pluginStatuses = new Map>(); private readonly update$ = new BehaviorSubject(true); private readonly defaultInheritedStatus$: Observable; + private newRegistrationsAllowed = true; constructor(private readonly deps: Deps) { this.defaultInheritedStatus$ = this.deps.core$.pipe( @@ -35,10 +45,19 @@ export class PluginsStatusService { } public set(plugin: PluginName, status$: Observable) { + if (!this.newRegistrationsAllowed) { + throw new Error( + `Custom statuses cannot be registered after setup, plugin [${plugin}] attempted` + ); + } this.pluginStatuses.set(plugin, status$); this.update$.next(true); // trigger all existing Observables to update from the new source Observable } + public blockNewRegistrations() { + this.newRegistrationsAllowed = false; + } + public getAll$(): Observable> { return this.getPluginStatuses$([...this.deps.pluginDependencies.keys()]); } @@ -86,13 +105,22 @@ export class PluginsStatusService { return this.update$.pipe( switchMap(() => { const pluginStatuses = plugins - .map( - (depName) => - [depName, this.pluginStatuses.get(depName) ?? this.getDerivedStatus$(depName)] as [ - PluginName, - Observable - ] - ) + .map((depName) => { + const pluginStatus = this.pluginStatuses.get(depName) + ? this.pluginStatuses.get(depName)!.pipe( + timeoutWith( + STATUS_TIMEOUT_MS, + this.pluginStatuses.get(depName)!.pipe( + startWith({ + level: ServiceStatusLevels.unavailable, + summary: `Status check timed out after ${STATUS_TIMEOUT_MS / 1000}s`, + }) + ) + ) + ) + : this.getDerivedStatus$(depName); + return [depName, pluginStatus] as [PluginName, Observable]; + }) .map(([pName, status$]) => status$.pipe(map((status) => [pName, status] as [PluginName, ServiceStatus])) ); diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index b8c19508a5d61..d4dc8ed3d4d72 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -135,9 +135,11 @@ export class StatusService implements CoreService { } public start() { - if (!this.overall$) { - throw new Error('cannot call `start` before `setup`'); + if (!this.pluginsStatus || !this.overall$) { + throw new Error(`StatusService#setup must be called before #start`); } + this.pluginsStatus.blockNewRegistrations(); + getOverallStatusChanges(this.overall$, this.stop$).subscribe((message) => { this.logger.info(message); }); diff --git a/src/core/server/status/types.ts b/src/core/server/status/types.ts index 411b942c8eb33..bfca4c74d9365 100644 --- a/src/core/server/status/types.ts +++ b/src/core/server/status/types.ts @@ -196,6 +196,9 @@ export interface StatusServiceSetup { * Completely overrides the default inherited status. * * @remarks + * The first emission from this Observable should occur within 30s, else this plugin's status will fallback to + * `unavailable` until the first emission. + * * See the {@link StatusServiceSetup.derivedStatus$} API for leveraging the default status * calculation that is provided by Core. */ diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index 6c7cdfa43cf57..61e55284a20b8 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -17,7 +17,7 @@ const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo. const savedObjectIndex = `.kibana_${kibanaVersion}_001`; describe('uiSettings/routes', function () { - jest.setTimeout(10000); + jest.setTimeout(120_000); beforeAll(startServers); /* eslint-disable jest/valid-describe */ diff --git a/src/core/server/ui_settings/integration_tests/lib/servers.ts b/src/core/server/ui_settings/integration_tests/lib/servers.ts index b18d9926649aa..96ba08a0728ab 100644 --- a/src/core/server/ui_settings/integration_tests/lib/servers.ts +++ b/src/core/server/ui_settings/integration_tests/lib/servers.ts @@ -75,8 +75,10 @@ export function getServices() { export async function stopServers() { services = null!; - if (servers) { + if (esServer) { await esServer.stop(); + } + if (kbn) { await kbn.stop(); } } diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 2995ffd08e5c0..393cc6ce495fb 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -7,7 +7,13 @@ */ import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; -import { createTestEsCluster, esTestConfig, kibanaServerTestUser, kibanaTestUser } from '@kbn/test'; +import { + createTestEsCluster, + CreateTestEsClusterOptions, + esTestConfig, + kibanaServerTestUser, + kibanaTestUser, +} from '@kbn/test'; import { defaultsDeep } from 'lodash'; import { resolve } from 'path'; import { BehaviorSubject } from 'rxjs'; @@ -153,10 +159,7 @@ export function createTestServers({ }: { adjustTimeout: (timeout: number) => void; settings?: { - es?: { - license: 'basic' | 'gold' | 'trial'; - [key: string]: any; - }; + es?: Partial; kbn?: { /** * An array of directories paths, passed in via absolute path strings @@ -217,7 +220,7 @@ export function createTestServers({ if (['gold', 'trial'].includes(license)) { // Override provided configs kbnSettings.elasticsearch = { - hosts: [esTestConfig.getUrl()], + hosts: es.getHostUrls(), username: kibanaServerTestUser.username, password: kibanaServerTestUser.password, }; @@ -226,7 +229,7 @@ export function createTestServers({ return { stop: async () => await es.cleanup(), es, - hosts: [esTestConfig.getUrl()], + hosts: es.getHostUrls(), username: kibanaServerTestUser.username, password: kibanaServerTestUser.password, }; diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 416b562b175b6..3a97c2fd6f010 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -84,7 +84,10 @@ export interface SavedObject { migrationVersion?: SavedObjectsMigrationVersion; /** A semver value that is used when upgrading objects between Kibana versions. */ coreMigrationVersion?: string; - /** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */ + /** + * Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with + * `namespaceType: 'agnostic'`. + */ namespaces?: string[]; /** * The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration diff --git a/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat new file mode 100755 index 0000000000000..9221af3142e61 --- /dev/null +++ b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat @@ -0,0 +1,35 @@ +@echo off + +SETLOCAL ENABLEDELAYEDEXPANSION + +set SCRIPT_DIR=%~dp0 +for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI + +set NODE=%DIR%\node\node.exe + +If Not Exist "%NODE%" ( + Echo unable to find usable node.js executable. + Exit /B 1 +) + +set CONFIG_DIR=%KBN_PATH_CONF% +If [%KBN_PATH_CONF%] == [] ( + set "CONFIG_DIR=%DIR%\config" +) + +IF EXIST "%CONFIG_DIR%\node.options" ( + for /F "usebackq eol=# tokens=*" %%i in ("%CONFIG_DIR%\node.options") do ( + If [!NODE_OPTIONS!] == [] ( + set "NODE_OPTIONS=%%i" + ) Else ( + set "NODE_OPTIONS=!NODE_OPTIONS! %%i" + ) + ) +) + +TITLE Kibana Encryption Keys +"%NODE%" "%DIR%\src\cli_encryption_keys\dist" %* + +:finally + +ENDLOCAL diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index a9b2dd6aefdda..a224793bace3f 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -31,6 +31,17 @@ kibana_vars=( csp.rules csp.strict csp.warnLegacyBrowsers + csp.script_src + csp.worker_src + csp.style_src + csp.connect_src + csp.default_src + csp.font_src + csp.frame_src + csp.img_src + csp.frame_ancestors + csp.report_uri + csp.report_to data.autocomplete.valueSuggestions.terminateAfter data.autocomplete.valueSuggestions.timeout elasticsearch.customHeaders @@ -69,7 +80,6 @@ kibana_vars=( logging.appenders logging.appenders.console logging.appenders.file - logging.appenders.rolling-file logging.dest logging.json logging.loggers @@ -204,8 +214,8 @@ kibana_vars=( xpack.actions.proxyUrl xpack.actions.rejectUnauthorized xpack.actions.responseTimeout - xpack.actions.tls.proxyVerificationMode - xpack.actions.tls.verificationMode + xpack.actions.ssl.proxyVerificationMode + xpack.actions.ssl.verificationMode xpack.alerting.healthCheck.interval xpack.alerting.invalidateApiKeysTask.interval xpack.alerting.invalidateApiKeysTask.removalDelay @@ -380,7 +390,8 @@ kibana_vars=( xpack.task_manager.monitored_aggregated_stats_refresh_rate xpack.task_manager.monitored_stats_required_freshness xpack.task_manager.monitored_stats_running_average_window - xpack.task_manager.monitored_stats_warn_delayed_task_start_in_seconds + xpack.task_manager.monitored_stats_health_verbose_log.enabled + xpack.task_manager.monitored_stats_health_verbose_log.warn_delayed_task_start_in_seconds xpack.task_manager.monitored_task_execution_thresholds xpack.task_manager.poll_interval xpack.task_manager.request_capacity diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index ebf56166a8922..b3b7bf5e8eed7 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -10,6 +10,7 @@ // used as dependencies or dev dependencies export const LICENSE_ALLOWED = [ 'Elastic-License', + 'Elastic License 2.0', 'SSPL-1.0 OR Elastic License 2.0', '0BSD', '(BSD-2-Clause OR MIT OR Apache-2.0)', @@ -72,6 +73,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint + '@elastic/ems-client@7.14.0': ['Elastic License 2.0'], // TODO can be removed if the https://github.com/jindw/xmldom/issues/239 is released 'xmldom@0.1.27': ['MIT'], diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index 050743114f657..2c54bb8dba179 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -22,6 +22,9 @@ export const PROJECTS = [ new Project(resolve(REPO_ROOT, 'x-pack/plugins/security_solution/cypress/tsconfig.json'), { name: 'security_solution/cypress', }), + new Project(resolve(REPO_ROOT, 'x-pack/plugins/osquery/cypress/tsconfig.json'), { + name: 'osquery/cypress', + }), new Project(resolve(REPO_ROOT, 'x-pack/plugins/apm/e2e/tsconfig.json'), { name: 'apm/cypress', disableTypeCheck: true, @@ -55,6 +58,9 @@ export const PROJECTS = [ ...glob .sync('test/interpreter_functional/plugins/*/tsconfig.json', { cwd: REPO_ROOT }) .map((path) => new Project(resolve(REPO_ROOT, path))), + ...glob + .sync('test/server_integration/__fixtures__/plugins/*/tsconfig.json', { cwd: REPO_ROOT }) + .map((path) => new Project(resolve(REPO_ROOT, path))), ]; export function filterProjectsByFlag(projectFlag?: string) { diff --git a/src/plugins/charts/public/services/palettes/types.ts b/src/plugins/charts/public/services/palettes/types.ts index 6f13f62178364..7a870504270d7 100644 --- a/src/plugins/charts/public/services/palettes/types.ts +++ b/src/plugins/charts/public/services/palettes/types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Ast } from '@kbn/interpreter/common'; +import { ExpressionAstExpression } from '../../../../expressions/common/ast'; /** * Information about a series in a chart used to determine its color. @@ -78,7 +78,7 @@ export interface PaletteDefinition { * This function should be used to pass the palette to the expression function applying color and other styles * @param state The internal state of the palette */ - toExpression: (state?: T) => Ast; + toExpression: (state?: T) => ExpressionAstExpression; /** * Color a series according to the internal rules of the palette. * @param series The current series along with its ancestors. diff --git a/src/plugins/console/public/application/components/welcome_panel.tsx b/src/plugins/console/public/application/components/welcome_panel.tsx index eb746e313d228..8514d41c04a51 100644 --- a/src/plugins/console/public/application/components/welcome_panel.tsx +++ b/src/plugins/console/public/application/components/welcome_panel.tsx @@ -27,7 +27,7 @@ interface Props { export function WelcomePanel(props: Props) { return ( - +

    diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 9f56740fdac22..afe339f3f43a2 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -603,7 +603,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` } > -
    -
    +
    @@ -950,7 +950,7 @@ exports[`DashboardEmptyScreen renders correctly with view mode 1`] = ` } > -
    -
    +
    diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index c584b44286e07..ff7708689c221 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -22,11 +22,14 @@ export { DashboardUrlGenerator, DashboardFeatureFlagConfig, } from './plugin'; + export { DASHBOARD_APP_URL_GENERATOR, createDashboardUrlGenerator, DashboardUrlGeneratorState, } from './url_generator'; +export { DashboardAppLocator, DashboardAppLocatorParams } from './locator'; + export { DashboardSavedObject } from './saved_dashboards'; export { SavedDashboardPanel, DashboardContainerInput } from './types'; diff --git a/src/plugins/dashboard/public/locator.test.ts b/src/plugins/dashboard/public/locator.test.ts new file mode 100644 index 0000000000000..0b647ac00ce31 --- /dev/null +++ b/src/plugins/dashboard/public/locator.test.ts @@ -0,0 +1,323 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DashboardAppLocatorDefinition } from './locator'; +import { hashedItemStore } from '../../kibana_utils/public'; +import { mockStorage } from '../../kibana_utils/public/storage/hashed_item_store/mock'; +import { esFilters } from '../../data/public'; + +describe('dashboard locator', () => { + beforeEach(() => { + // @ts-ignore + hashedItemStore.storage = mockStorage; + }); + + test('creates a link to a saved dashboard', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({}); + + expect(location).toMatchObject({ + app: 'dashboards', + path: '#/create?_a=()&_g=()', + state: {}, + }); + }); + + test('creates a link with global time range set up', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: '#/create?_a=()&_g=(time:(from:now-15m,mode:relative,to:now))', + state: {}, + }); + }); + + test('creates a link with filters, time range, refresh interval and query to a saved object', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + refreshInterval: { pause: false, value: 300 }, + dashboardId: '123', + filters: [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'hi' }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'hi' }, + $state: { + store: esFilters.FilterStateStore.GLOBAL_STATE, + }, + }, + ], + query: { query: 'bye', language: 'kuery' }, + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: `#/view/123?_a=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:hi))),query:(language:kuery,query:bye))&_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:hi))),refreshInterval:(pause:!f,value:300),time:(from:now-15m,mode:relative,to:now))`, + state: {}, + }); + }); + + test('searchSessionId', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + refreshInterval: { pause: false, value: 300 }, + dashboardId: '123', + filters: [], + query: { query: 'bye', language: 'kuery' }, + searchSessionId: '__sessionSearchId__', + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: `#/view/123?_a=(filters:!(),query:(language:kuery,query:bye))&_g=(filters:!(),refreshInterval:(pause:!f,value:300),time:(from:now-15m,mode:relative,to:now))&searchSessionId=__sessionSearchId__`, + state: {}, + }); + }); + + test('savedQuery', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + savedQuery: '__savedQueryId__', + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: `#/create?_a=(savedQuery:__savedQueryId__)&_g=()`, + state: {}, + }); + expect(location.path).toContain('__savedQueryId__'); + }); + + test('panels', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + panels: [{ fakePanelContent: 'fakePanelContent' }] as any, + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: `#/create?_a=(panels:!((fakePanelContent:fakePanelContent)))&_g=()`, + state: {}, + }); + }); + + test('if no useHash setting is given, uses the one was start services', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: true, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + + expect(location.path.indexOf('relative')).toBe(-1); + }); + + test('can override a false useHash ui setting', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + useHash: true, + }); + + expect(location.path.indexOf('relative')).toBe(-1); + }); + + test('can override a true useHash ui setting', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: true, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + useHash: false, + }); + + expect(location.path.indexOf('relative')).toBeGreaterThan(1); + }); + + describe('preserving saved filters', () => { + const savedFilter1 = { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'savedfilter1' }, + }; + + const savedFilter2 = { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'savedfilter2' }, + }; + + const appliedFilter = { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'appliedfilter' }, + }; + + test('attaches filters from destination dashboard', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + return dashboardId === 'dashboard1' + ? [savedFilter1] + : dashboardId === 'dashboard2' + ? [savedFilter2] + : []; + }, + }); + + const location1 = await definition.getLocation({ + dashboardId: 'dashboard1', + filters: [appliedFilter], + }); + + expect(location1.path).toEqual(expect.stringContaining('query:savedfilter1')); + expect(location1.path).toEqual(expect.stringContaining('query:appliedfilter')); + + const location2 = await definition.getLocation({ + dashboardId: 'dashboard2', + filters: [appliedFilter], + }); + + expect(location2.path).toEqual(expect.stringContaining('query:savedfilter2')); + expect(location2.path).toEqual(expect.stringContaining('query:appliedfilter')); + }); + + test("doesn't fail if can't retrieve filters from destination dashboard", async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + if (dashboardId === 'dashboard1') { + throw new Error('Not found'); + } + return []; + }, + }); + + const location = await definition.getLocation({ + dashboardId: 'dashboard1', + filters: [appliedFilter], + }); + + expect(location.path).not.toEqual(expect.stringContaining('query:savedfilter1')); + expect(location.path).toEqual(expect.stringContaining('query:appliedfilter')); + }); + + test('can enforce empty filters', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + if (dashboardId === 'dashboard1') { + return [savedFilter1]; + } + return []; + }, + }); + + const location = await definition.getLocation({ + dashboardId: 'dashboard1', + filters: [], + preserveSavedFilters: false, + }); + + expect(location.path).not.toEqual(expect.stringContaining('query:savedfilter1')); + expect(location.path).not.toEqual(expect.stringContaining('query:appliedfilter')); + expect(location.path).toMatchInlineSnapshot( + `"#/view/dashboard1?_a=(filters:!())&_g=(filters:!())"` + ); + }); + + test('no filters in result url if no filters applied', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + if (dashboardId === 'dashboard1') { + return [savedFilter1]; + } + return []; + }, + }); + + const location = await definition.getLocation({ + dashboardId: 'dashboard1', + }); + + expect(location.path).not.toEqual(expect.stringContaining('filters')); + expect(location.path).toMatchInlineSnapshot(`"#/view/dashboard1?_a=()&_g=()"`); + }); + + test('can turn off preserving filters', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + if (dashboardId === 'dashboard1') { + return [savedFilter1]; + } + return []; + }, + }); + + const location = await definition.getLocation({ + dashboardId: 'dashboard1', + filters: [appliedFilter], + preserveSavedFilters: false, + }); + + expect(location.path).not.toEqual(expect.stringContaining('query:savedfilter1')); + expect(location.path).toEqual(expect.stringContaining('query:appliedfilter')); + }); + }); +}); diff --git a/src/plugins/dashboard/public/locator.ts b/src/plugins/dashboard/public/locator.ts new file mode 100644 index 0000000000000..e154351819ee9 --- /dev/null +++ b/src/plugins/dashboard/public/locator.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SerializableState } from 'src/plugins/kibana_utils/common'; +import type { TimeRange, Filter, Query, QueryState, RefreshInterval } from '../../data/public'; +import type { LocatorDefinition, LocatorPublic } from '../../share/public'; +import type { SavedDashboardPanel } from '../common/types'; +import { esFilters } from '../../data/public'; +import { setStateToKbnUrl } from '../../kibana_utils/public'; +import { ViewMode } from '../../embeddable/public'; +import { DashboardConstants } from './dashboard_constants'; + +const cleanEmptyKeys = (stateObj: Record) => { + Object.keys(stateObj).forEach((key) => { + if (stateObj[key] === undefined) { + delete stateObj[key]; + } + }); + return stateObj; +}; + +export const DASHBOARD_APP_LOCATOR = 'DASHBOARD_APP_LOCATOR'; + +export interface DashboardAppLocatorParams extends SerializableState { + /** + * If given, the dashboard saved object with this id will be loaded. If not given, + * a new, unsaved dashboard will be loaded up. + */ + dashboardId?: string; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval & SerializableState; + + /** + * Optionally apply filers. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has filters saved with it, this will _replace_ those filters. + */ + filters?: Filter[]; + /** + * Optionally set a query. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has a query saved with it, this will _replace_ that query. + */ + query?: Query; + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; + + /** + * When `true` filters from saved filters from destination dashboard as merged with applied filters + * When `false` applied filters take precedence and override saved filters + * + * true is default + */ + preserveSavedFilters?: boolean; + + /** + * View mode of the dashboard. + */ + viewMode?: ViewMode; + + /** + * Search search session ID to restore. + * (Background search) + */ + searchSessionId?: string; + + /** + * List of dashboard panels + */ + panels?: SavedDashboardPanel[] & SerializableState; + + /** + * Saved query ID + */ + savedQuery?: string; +} + +export type DashboardAppLocator = LocatorPublic; + +export interface DashboardAppLocatorDependencies { + useHashedUrl: boolean; + getDashboardFilterFields: (dashboardId: string) => Promise; +} + +export class DashboardAppLocatorDefinition implements LocatorDefinition { + public readonly id = DASHBOARD_APP_LOCATOR; + + constructor(protected readonly deps: DashboardAppLocatorDependencies) {} + + public readonly getLocation = async (params: DashboardAppLocatorParams) => { + const useHash = params.useHash ?? this.deps.useHashedUrl; + const hash = params.dashboardId ? `view/${params.dashboardId}` : `create`; + + const getSavedFiltersFromDestinationDashboardIfNeeded = async (): Promise => { + if (params.preserveSavedFilters === false) return []; + if (!params.dashboardId) return []; + try { + return await this.deps.getDashboardFilterFields(params.dashboardId); + } catch (e) { + // In case dashboard is missing, build the url without those filters. + // The Dashboard app will handle redirect to landing page with a toast message. + return []; + } + }; + + // leave filters `undefined` if no filters was applied + // in this case dashboard will restore saved filters on its own + const filters = params.filters && [ + ...(await getSavedFiltersFromDestinationDashboardIfNeeded()), + ...params.filters, + ]; + + let path = setStateToKbnUrl( + '_a', + cleanEmptyKeys({ + query: params.query, + filters: filters?.filter((f) => !esFilters.isFilterPinned(f)), + viewMode: params.viewMode, + panels: params.panels, + savedQuery: params.savedQuery, + }), + { useHash }, + `#/${hash}` + ); + + path = setStateToKbnUrl( + '_g', + cleanEmptyKeys({ + time: params.timeRange, + filters: filters?.filter((f) => esFilters.isFilterPinned(f)), + refreshInterval: params.refreshInterval, + }), + { useHash }, + path + ); + + if (params.searchSessionId) { + path = `${path}&${DashboardConstants.SEARCH_SESSION_ID}=${params.searchSessionId}`; + } + + return { + app: DashboardConstants.DASHBOARDS_ID, + path, + state: {}, + }; + }; +} diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index b5d6eda71ca4a..53a8e90a8c35c 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -72,6 +72,7 @@ import { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGeneratorState, } from './url_generator'; +import { DashboardAppLocatorDefinition, DashboardAppLocator } from './locator'; import { createSavedDashboardLoader } from './saved_dashboards'; import { DashboardConstants } from './dashboard_constants'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; @@ -121,14 +122,25 @@ export interface DashboardStartDependencies { visualizations: VisualizationsStart; } -export type DashboardSetup = void; +export interface DashboardSetup { + locator?: DashboardAppLocator; +} export interface DashboardStart { getSavedDashboardLoader: () => SavedObjectLoader; getDashboardContainerByValueRenderer: () => ReturnType< typeof createDashboardContainerByValueRenderer >; + /** + * @deprecated Use dashboard locator instead. Dashboard locator is available + * under `.locator` key. This dashboard URL generator will be removed soon. + * + * ```ts + * plugins.dashboard.locator.getLocation({ ... }); + * ``` + */ dashboardUrlGenerator?: DashboardUrlGenerator; + locator?: DashboardAppLocator; dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; } @@ -142,7 +154,11 @@ export class DashboardPlugin private currentHistory: ScopedHistory | undefined = undefined; private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig; + /** + * @deprecated Use locator instead. + */ private dashboardUrlGenerator?: DashboardUrlGenerator; + private locator?: DashboardAppLocator; public setup( core: CoreSetup, @@ -205,6 +221,19 @@ export class DashboardPlugin }; }; + if (share) { + this.locator = share.url.locators.create( + new DashboardAppLocatorDefinition({ + useHashedUrl: core.uiSettings.get('state:storeInSessionStorage'), + getDashboardFilterFields: async (dashboardId: string) => { + const [, , selfStart] = await core.getStartServices(); + const dashboard = await selfStart.getSavedDashboardLoader().get(dashboardId); + return dashboard?.searchSource?.getField('filter') ?? []; + }, + }) + ); + } + const { appMounted, appUnMounted, @@ -333,6 +362,10 @@ export class DashboardPlugin order: 100, }); } + + return { + locator: this.locator, + }; } public start(core: CoreStart, plugins: DashboardStartDependencies): DashboardStart { @@ -417,6 +450,7 @@ export class DashboardPlugin }); }, dashboardUrlGenerator: this.dashboardUrlGenerator, + locator: this.locator, dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!, }; } diff --git a/src/plugins/dashboard/public/url_generator.ts b/src/plugins/dashboard/public/url_generator.ts index 58036ef70fa4a..5c0cd32ee5a16 100644 --- a/src/plugins/dashboard/public/url_generator.ts +++ b/src/plugins/dashboard/public/url_generator.ts @@ -26,6 +26,9 @@ export const GLOBAL_STATE_STORAGE_KEY = '_g'; export const DASHBOARD_APP_URL_GENERATOR = 'DASHBOARD_APP_URL_GENERATOR'; +/** + * @deprecated Use dashboard locator instead. + */ export interface DashboardUrlGeneratorState { /** * If given, the dashboard saved object with this id will be loaded. If not given, @@ -88,6 +91,9 @@ export interface DashboardUrlGeneratorState { savedQuery?: string; } +/** + * @deprecated Use dashboard locator instead. + */ export const createDashboardUrlGenerator = ( getStartServices: () => Promise<{ appBasePath: string; diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts index 4ebca5ba8965e..0bd100b3d5803 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts @@ -7,7 +7,7 @@ */ import semver from 'semver'; -import { get, flow } from 'lodash'; +import { get, flow, identity } from 'lodash'; import { SavedObjectAttributes, SavedObjectMigrationFn, @@ -25,7 +25,9 @@ import { convertSavedDashboardPanelToPanelState, } from '../../common/embeddable/embeddable_saved_object_converters'; import { SavedObjectEmbeddableInput } from '../../../embeddable/common'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../data/common'; import { SerializableValue } from '../../../kibana_utils/common'; +import { replaceIndexPatternReference } from './replace_index_pattern_reference'; function migrateIndexPattern(doc: DashboardDoc700To720) { const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON'); @@ -43,7 +45,7 @@ function migrateIndexPattern(doc: DashboardDoc700To720) { searchSource.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; doc.references.push({ name: searchSource.indexRefName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: searchSource.index, }); delete searchSource.index; @@ -56,7 +58,7 @@ function migrateIndexPattern(doc: DashboardDoc700To720) { filterRow.meta.indexRefName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; doc.references.push({ name: filterRow.meta.indexRefName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: filterRow.meta.index, }); delete filterRow.meta.index; @@ -214,12 +216,14 @@ export interface DashboardSavedObjectTypeMigrationsDeps { export const createDashboardSavedObjectTypeMigrations = ( deps: DashboardSavedObjectTypeMigrationsDeps ): SavedObjectMigrationMap => { - const embeddableMigrations = deps.embeddable - .getMigrationVersions() - .filter((version) => semver.gt(version, '7.12.0')) - .map((version): [string, SavedObjectMigrationFn] => { - return [version, migrateByValuePanels(deps, version)]; - }); + const embeddableMigrations = Object.fromEntries( + deps.embeddable + .getMigrationVersions() + .filter((version) => semver.gt(version, '7.12.0')) + .map((version): [string, SavedObjectMigrationFn] => { + return [version, migrateByValuePanels(deps, version)]; + }) + ); return { /** @@ -237,12 +241,15 @@ export const createDashboardSavedObjectTypeMigrations = ( '7.3.0': flow(migrations730), '7.9.3': flow(migrateMatchAllQuery), '7.11.0': flow(createExtractPanelReferencesMigration(deps)), - ...Object.fromEntries(embeddableMigrations), + + ...embeddableMigrations, /** * Any dashboard saved object migrations that come after this point will have to be wary of * potentially overwriting embeddable migrations. An example of how to mitigate this follows: */ - // '7.x': flow(yourNewMigrationFunction, embeddableMigrations['7.x']) + // '7.x': flow(yourNewMigrationFunction, embeddableMigrations['7.x'] ?? identity), + + '7.14.0': flow(replaceIndexPatternReference, embeddableMigrations['7.14.0'] ?? identity), }; }; diff --git a/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.test.ts b/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.test.ts new file mode 100644 index 0000000000000..01207fb4e3404 --- /dev/null +++ b/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { SavedObjectMigrationContext, SavedObjectMigrationFn } from 'kibana/server'; + +import { replaceIndexPatternReference } from './replace_index_pattern_reference'; + +describe('replaceIndexPatternReference', () => { + const savedObjectMigrationContext = (null as unknown) as SavedObjectMigrationContext; + + test('should replace index_pattern to index-pattern', () => { + const migratedDoc = replaceIndexPatternReference( + { + references: [ + { + name: 'name', + type: 'index_pattern', + }, + ], + } as Parameters[0], + savedObjectMigrationContext + ); + + expect(migratedDoc).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "name": "name", + "type": "index-pattern", + }, + ], + } + `); + }); +}); diff --git a/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.ts b/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.ts new file mode 100644 index 0000000000000..ddd1c45841b9c --- /dev/null +++ b/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectMigrationFn } from 'kibana/server'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../data/common'; + +export const replaceIndexPatternReference: SavedObjectMigrationFn = (doc) => ({ + ...doc, + references: Array.isArray(doc.references) + ? doc.references.map((reference) => { + if (reference.type === 'index_pattern') { + reference.type = INDEX_PATTERN_SAVED_OBJECT_TYPE; + } + return reference; + }) + : doc.references, +}); diff --git a/src/plugins/data/common/constants.ts b/src/plugins/data/common/constants.ts index 79a9e0ac5451b..c6bfbfc75c290 100644 --- a/src/plugins/data/common/constants.ts +++ b/src/plugins/data/common/constants.ts @@ -9,6 +9,9 @@ export const DEFAULT_QUERY_LANGUAGE = 'kuery'; export const KIBANA_USER_QUERY_LANGUAGE_KEY = 'kibana.userQueryLanguage'; +/** @public **/ +export const INDEX_PATTERN_SAVED_OBJECT_TYPE = 'index-pattern'; + export const UI_SETTINGS = { META_FIELDS: 'metaFields', DOC_HIGHLIGHT: 'doc_table:highlight', diff --git a/src/plugins/data/common/es_query/es_query/build_es_query.ts b/src/plugins/data/common/es_query/es_query/build_es_query.ts index 45724796c3518..d7b3c630d1a6e 100644 --- a/src/plugins/data/common/es_query/es_query/build_es_query.ts +++ b/src/plugins/data/common/es_query/es_query/build_es_query.ts @@ -10,9 +10,9 @@ import { groupBy, has, isEqual } from 'lodash'; import { buildQueryFromKuery } from './from_kuery'; import { buildQueryFromFilters } from './from_filters'; import { buildQueryFromLucene } from './from_lucene'; -import { IIndexPattern } from '../../index_patterns'; import { Filter } from '../filters'; import { Query } from '../../query/types'; +import { IndexPatternBase } from './types'; export interface EsQueryConfig { allowLeadingWildcards: boolean; @@ -36,7 +36,7 @@ function removeMatchAll(filters: T[]) { * config contains dateformat:tz */ export function buildEsQuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queries: Query | Query[], filters: Filter | Filter[], config: EsQueryConfig = { diff --git a/src/plugins/data/common/es_query/es_query/filter_matches_index.ts b/src/plugins/data/common/es_query/es_query/filter_matches_index.ts index 478263d5ce601..b376436756092 100644 --- a/src/plugins/data/common/es_query/es_query/filter_matches_index.ts +++ b/src/plugins/data/common/es_query/es_query/filter_matches_index.ts @@ -6,15 +6,16 @@ * Side Public License, v 1. */ -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; import { Filter } from '../filters'; +import { IndexPatternBase } from './types'; /* * TODO: We should base this on something better than `filter.meta.key`. We should probably modify * this to check if `filter.meta.index` matches `indexPattern.id` instead, but that's a breaking * change. */ -export function filterMatchesIndex(filter: Filter, indexPattern?: IIndexPattern | null) { +export function filterMatchesIndex(filter: Filter, indexPattern?: IndexPatternBase | null) { if (!filter.meta?.key || !indexPattern) { return true; } diff --git a/src/plugins/data/common/es_query/es_query/from_filters.ts b/src/plugins/data/common/es_query/es_query/from_filters.ts index e50862235af1d..7b3c58d45a569 100644 --- a/src/plugins/data/common/es_query/es_query/from_filters.ts +++ b/src/plugins/data/common/es_query/es_query/from_filters.ts @@ -10,7 +10,7 @@ import { isUndefined } from 'lodash'; import { migrateFilter } from './migrate_filter'; import { filterMatchesIndex } from './filter_matches_index'; import { Filter, cleanFilter, isFilterDisabled } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; import { handleNestedFilter } from './handle_nested_filter'; /** @@ -45,7 +45,7 @@ const translateToQuery = (filter: Filter) => { export const buildQueryFromFilters = ( filters: Filter[] = [], - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex: boolean = false ) => { filters = filters.filter((filter) => filter && !isFilterDisabled(filter)); diff --git a/src/plugins/data/common/es_query/es_query/from_kuery.ts b/src/plugins/data/common/es_query/es_query/from_kuery.ts index afedaae45872b..3eccfd8776113 100644 --- a/src/plugins/data/common/es_query/es_query/from_kuery.ts +++ b/src/plugins/data/common/es_query/es_query/from_kuery.ts @@ -7,11 +7,11 @@ */ import { fromKueryExpression, toElasticsearchQuery, nodeTypes, KueryNode } from '../kuery'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; import { Query } from '../../query/types'; export function buildQueryFromKuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queries: Query[] = [], allowLeadingWildcards: boolean = false, dateFormatTZ?: string @@ -24,7 +24,7 @@ export function buildQueryFromKuery( } function buildQuery( - indexPattern: IIndexPattern | undefined, + indexPattern: IndexPatternBase | undefined, queryASTs: KueryNode[], config: Record = {} ) { diff --git a/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts b/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts index ee5305132042a..d312d034df564 100644 --- a/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts +++ b/src/plugins/data/common/es_query/es_query/handle_nested_filter.test.ts @@ -9,13 +9,14 @@ import { handleNestedFilter } from './handle_nested_filter'; import { fields } from '../../index_patterns/mocks'; import { buildPhraseFilter, buildQueryFilter } from '../filters'; -import { IFieldType, IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; +import { IFieldType } from '../../index_patterns'; describe('handleNestedFilter', function () { - const indexPattern: IIndexPattern = ({ + const indexPattern: IndexPatternBase = { id: 'logstash-*', fields, - } as unknown) as IIndexPattern; + }; it("should return the filter's query wrapped in nested query if the target field is nested", () => { const field = getField('nestedField.child'); diff --git a/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts b/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts index 93927d81565ef..60e92769503fb 100644 --- a/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts +++ b/src/plugins/data/common/es_query/es_query/handle_nested_filter.ts @@ -7,9 +7,9 @@ */ import { getFilterField, cleanFilter, Filter } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; -export const handleNestedFilter = (filter: Filter, indexPattern?: IIndexPattern) => { +export const handleNestedFilter = (filter: Filter, indexPattern?: IndexPatternBase) => { if (!indexPattern) return filter; const fieldName = getFilterField(filter); diff --git a/src/plugins/data/common/es_query/es_query/index.ts b/src/plugins/data/common/es_query/es_query/index.ts index 31529480c8ac9..c10ea5846ae3f 100644 --- a/src/plugins/data/common/es_query/es_query/index.ts +++ b/src/plugins/data/common/es_query/es_query/index.ts @@ -11,3 +11,4 @@ export { buildQueryFromFilters } from './from_filters'; export { luceneStringToDsl } from './lucene_string_to_dsl'; export { decorateQuery } from './decorate_query'; export { getEsQueryConfig } from './get_es_query_config'; +export { IndexPatternBase } from './types'; diff --git a/src/plugins/data/common/es_query/es_query/migrate_filter.ts b/src/plugins/data/common/es_query/es_query/migrate_filter.ts index c7c44d019a31c..9bd78b092fc18 100644 --- a/src/plugins/data/common/es_query/es_query/migrate_filter.ts +++ b/src/plugins/data/common/es_query/es_query/migrate_filter.ts @@ -9,7 +9,7 @@ import { get, omit } from 'lodash'; import { getConvertedValueForField } from '../filters'; import { Filter } from '../filters'; -import { IIndexPattern } from '../../index_patterns'; +import { IndexPatternBase } from './types'; export interface DeprecatedMatchPhraseFilter extends Filter { query: { @@ -28,7 +28,7 @@ function isDeprecatedMatchPhraseFilter(filter: any): filter is DeprecatedMatchPh return Boolean(fieldName && get(filter, ['query', 'match', fieldName, 'type']) === 'phrase'); } -export function migrateFilter(filter: Filter, indexPattern?: IIndexPattern) { +export function migrateFilter(filter: Filter, indexPattern?: IndexPatternBase) { if (isDeprecatedMatchPhraseFilter(filter)) { const fieldName = Object.keys(filter.query.match)[0]; const params: Record = get(filter, ['query', 'match', fieldName]); diff --git a/src/plugins/data/common/es_query/es_query/types.ts b/src/plugins/data/common/es_query/es_query/types.ts new file mode 100644 index 0000000000000..2133736516049 --- /dev/null +++ b/src/plugins/data/common/es_query/es_query/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IFieldType } from '../../index_patterns'; + +export interface IndexPatternBase { + fields: IFieldType[]; + id?: string; +} diff --git a/src/plugins/data/common/es_query/filters/build_filters.ts b/src/plugins/data/common/es_query/filters/build_filters.ts index ba1bd0a615493..369f9530fb92b 100644 --- a/src/plugins/data/common/es_query/filters/build_filters.ts +++ b/src/plugins/data/common/es_query/filters/build_filters.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IIndexPattern, IFieldType } from '../..'; +import { IFieldType, IndexPatternBase } from '../..'; import { Filter, FILTERS, @@ -19,7 +19,7 @@ import { } from '.'; export function buildFilter( - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, field: IFieldType, type: FILTERS, negate: boolean, @@ -59,7 +59,7 @@ export function buildCustomFilter( } function buildBaseFilter( - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, field: IFieldType, type: FILTERS, params: any diff --git a/src/plugins/data/common/es_query/filters/exists_filter.ts b/src/plugins/data/common/es_query/filters/exists_filter.ts index 441a6bcb924b7..4836950c3bb27 100644 --- a/src/plugins/data/common/es_query/filters/exists_filter.ts +++ b/src/plugins/data/common/es_query/filters/exists_filter.ts @@ -7,7 +7,8 @@ */ import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; export type ExistsFilterMeta = FilterMeta; @@ -26,7 +27,7 @@ export const getExistsFilterField = (filter: ExistsFilter) => { return filter.exists && filter.exists.field; }; -export const buildExistsFilter = (field: IFieldType, indexPattern: IIndexPattern) => { +export const buildExistsFilter = (field: IFieldType, indexPattern: IndexPatternBase) => { return { meta: { index: indexPattern.id, diff --git a/src/plugins/data/common/es_query/filters/index.ts b/src/plugins/data/common/es_query/filters/index.ts index 133f5cd232e6f..fe7cdadabaee3 100644 --- a/src/plugins/data/common/es_query/filters/index.ts +++ b/src/plugins/data/common/es_query/filters/index.ts @@ -14,10 +14,8 @@ export * from './custom_filter'; export * from './exists_filter'; export * from './geo_bounding_box_filter'; export * from './geo_polygon_filter'; -export * from './get_display_value'; export * from './get_filter_field'; export * from './get_filter_params'; -export * from './get_index_pattern_from_filter'; export * from './match_all_filter'; export * from './meta_filter'; export * from './missing_filter'; diff --git a/src/plugins/data/common/es_query/filters/phrase_filter.ts b/src/plugins/data/common/es_query/filters/phrase_filter.ts index 85562435e68d0..27c1e85562097 100644 --- a/src/plugins/data/common/es_query/filters/phrase_filter.ts +++ b/src/plugins/data/common/es_query/filters/phrase_filter.ts @@ -8,7 +8,8 @@ import type { estypes } from '@elastic/elasticsearch'; import { get, isPlainObject } from 'lodash'; import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; export type PhraseFilterMeta = FilterMeta & { params?: { @@ -60,7 +61,7 @@ export const getPhraseFilterValue = (filter: PhraseFilter): PhraseFilterValue => export const buildPhraseFilter = ( field: IFieldType, value: any, - indexPattern: IIndexPattern + indexPattern: IndexPatternBase ): PhraseFilter => { const convertedValue = getConvertedValueForField(field, value); diff --git a/src/plugins/data/common/es_query/filters/phrases_filter.ts b/src/plugins/data/common/es_query/filters/phrases_filter.ts index 849c1b3faef2a..2694461fc1930 100644 --- a/src/plugins/data/common/es_query/filters/phrases_filter.ts +++ b/src/plugins/data/common/es_query/filters/phrases_filter.ts @@ -9,7 +9,8 @@ import { Filter, FilterMeta } from './meta_filter'; import { getPhraseScript } from './phrase_filter'; import { FILTERS } from './index'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '../es_query'; export type PhrasesFilterMeta = FilterMeta & { params: string[]; // The unformatted values @@ -34,17 +35,12 @@ export const getPhrasesFilterField = (filter: PhrasesFilter) => { export const buildPhrasesFilter = ( field: IFieldType, params: any[], - indexPattern: IIndexPattern + indexPattern: IndexPatternBase ) => { const index = indexPattern.id; const type = FILTERS.PHRASES; const key = field.name; - const format = (f: IFieldType, value: any) => - f && f.format && f.format.convert ? f.format.convert(value) : value; - - const value = params.map((v: any) => format(field, v)).join(', '); - let should; if (field.scripted) { should = params.map((v: any) => ({ @@ -59,7 +55,7 @@ export const buildPhrasesFilter = ( } return { - meta: { index, type, key, value, params }, + meta: { index, type, key, params }, query: { bool: { should, diff --git a/src/plugins/data/common/es_query/filters/range_filter.ts b/src/plugins/data/common/es_query/filters/range_filter.ts index a082b93c0a79a..9f1d9a5d08926 100644 --- a/src/plugins/data/common/es_query/filters/range_filter.ts +++ b/src/plugins/data/common/es_query/filters/range_filter.ts @@ -8,7 +8,8 @@ import type { estypes } from '@elastic/elasticsearch'; import { map, reduce, mapValues, get, keys, pickBy } from 'lodash'; import { Filter, FilterMeta } from './meta_filter'; -import { IIndexPattern, IFieldType } from '../../index_patterns'; +import { IFieldType } from '../../index_patterns'; +import { IndexPatternBase } from '..'; const OPERANDS_IN_RANGE = 2; @@ -83,17 +84,14 @@ export const getRangeFilterField = (filter: RangeFilter) => { }; const formatValue = (field: IFieldType, params: any[]) => - map(params, (val: any, key: string) => get(operators, key) + format(field, val)).join(' '); - -const format = (field: IFieldType, value: any) => - field && field.format && field.format.convert ? field.format.convert(value) : value; + map(params, (val: any, key: string) => get(operators, key) + val).join(' '); // Creates a filter where the value for the given field is in the given range // params should be an object containing `lt`, `lte`, `gt`, and/or `gte` export const buildRangeFilter = ( field: IFieldType, params: RangeFilterParams, - indexPattern: IIndexPattern, + indexPattern: IndexPatternBase, formattedValue?: string ): RangeFilter => { const filter: any = { meta: { index: indexPattern.id, params: {} } }; diff --git a/src/plugins/data/common/es_query/kuery/ast/ast.ts b/src/plugins/data/common/es_query/kuery/ast/ast.ts index be82128969968..3e7b25897cab7 100644 --- a/src/plugins/data/common/es_query/kuery/ast/ast.ts +++ b/src/plugins/data/common/es_query/kuery/ast/ast.ts @@ -10,10 +10,10 @@ import { JsonObject } from '@kbn/common-utils'; import { nodeTypes } from '../node_types/index'; import { KQLSyntaxError } from '../kuery_syntax_error'; import { KueryNode, DslQuery, KueryParseOptions } from '../types'; -import { IIndexPattern } from '../../../index_patterns/types'; // @ts-ignore import { parse as parseKuery } from './_generated_/kuery'; +import { IndexPatternBase } from '../..'; const fromExpression = ( expression: string | DslQuery, @@ -65,7 +65,7 @@ export const fromKueryExpression = ( */ export const toElasticsearchQuery = ( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ): JsonObject => { diff --git a/src/plugins/data/common/es_query/kuery/functions/and.ts b/src/plugins/data/common/es_query/kuery/functions/and.ts index 1989704cb627e..ba7d5d1f6645b 100644 --- a/src/plugins/data/common/es_query/kuery/functions/and.ts +++ b/src/plugins/data/common/es_query/kuery/functions/and.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(children: KueryNode[]) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(children: KueryNode[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/exists.ts b/src/plugins/data/common/es_query/kuery/functions/exists.ts index 5238fb1d8ee7f..fa6c37e6ba18f 100644 --- a/src/plugins/data/common/es_query/kuery/functions/exists.ts +++ b/src/plugins/data/common/es_query/kuery/functions/exists.ts @@ -8,7 +8,7 @@ import { get } from 'lodash'; import * as literal from '../node_types/literal'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { KueryNode, IFieldType, IndexPatternBase } from '../../..'; export function buildNodeParams(fieldName: string) { return { @@ -18,7 +18,7 @@ export function buildNodeParams(fieldName: string) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts b/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts index f2498f3ea2ad4..38a433b1b80ab 100644 --- a/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts +++ b/src/plugins/data/common/es_query/kuery/functions/geo_bounding_box.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { nodeTypes } from '../node_types'; import * as ast from '../ast'; -import { IIndexPattern, KueryNode, IFieldType, LatLon } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType, LatLon } from '../../..'; export function buildNodeParams(fieldName: string, params: any) { params = _.pick(params, 'topLeft', 'bottomRight'); @@ -26,7 +26,7 @@ export function buildNodeParams(fieldName: string, params: any) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts b/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts index 584a315930d9c..69de7248a7b38 100644 --- a/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts +++ b/src/plugins/data/common/es_query/kuery/functions/geo_polygon.ts @@ -8,7 +8,7 @@ import { nodeTypes } from '../node_types'; import * as ast from '../ast'; -import { IIndexPattern, KueryNode, IFieldType, LatLon } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType, LatLon } from '../../..'; import { LiteralTypeBuildNode } from '../node_types/types'; export function buildNodeParams(fieldName: string, points: LatLon[]) { @@ -25,7 +25,7 @@ export function buildNodeParams(fieldName: string, points: LatLon[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/is.ts b/src/plugins/data/common/es_query/kuery/functions/is.ts index a18ad230c3cae..55d036c2156f9 100644 --- a/src/plugins/data/common/es_query/kuery/functions/is.ts +++ b/src/plugins/data/common/es_query/kuery/functions/is.ts @@ -11,7 +11,7 @@ import { getPhraseScript } from '../../filters'; import { getFields } from './utils/get_fields'; import { getTimeZoneFromSettings } from '../../utils'; import { getFullFieldNameNode } from './utils/get_full_field_name_node'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType } from '../../..'; import * as ast from '../ast'; @@ -39,7 +39,7 @@ export function buildNodeParams(fieldName: string, value: any, isPhrase: boolean export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/nested.ts b/src/plugins/data/common/es_query/kuery/functions/nested.ts index bfd01ef39764c..46ceeaf3e5de6 100644 --- a/src/plugins/data/common/es_query/kuery/functions/nested.ts +++ b/src/plugins/data/common/es_query/kuery/functions/nested.ts @@ -8,7 +8,7 @@ import * as ast from '../ast'; import * as literal from '../node_types/literal'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(path: any, child: any) { const pathNode = @@ -20,7 +20,7 @@ export function buildNodeParams(path: any, child: any) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/not.ts b/src/plugins/data/common/es_query/kuery/functions/not.ts index ef4456897bcdd..f837cd261c814 100644 --- a/src/plugins/data/common/es_query/kuery/functions/not.ts +++ b/src/plugins/data/common/es_query/kuery/functions/not.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(child: KueryNode) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(child: KueryNode) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/or.ts b/src/plugins/data/common/es_query/kuery/functions/or.ts index 416687e7cde9c..7365cc39595e6 100644 --- a/src/plugins/data/common/es_query/kuery/functions/or.ts +++ b/src/plugins/data/common/es_query/kuery/functions/or.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; export function buildNodeParams(children: KueryNode[]) { return { @@ -17,7 +17,7 @@ export function buildNodeParams(children: KueryNode[]) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/range.ts b/src/plugins/data/common/es_query/kuery/functions/range.ts index 06b345e5821c3..caefa7e5373ca 100644 --- a/src/plugins/data/common/es_query/kuery/functions/range.ts +++ b/src/plugins/data/common/es_query/kuery/functions/range.ts @@ -13,7 +13,7 @@ import { getRangeScript, RangeFilterParams } from '../../filters'; import { getFields } from './utils/get_fields'; import { getTimeZoneFromSettings } from '../../utils'; import { getFullFieldNameNode } from './utils/get_full_field_name_node'; -import { IIndexPattern, KueryNode, IFieldType } from '../../..'; +import { IndexPatternBase, KueryNode, IFieldType } from '../../..'; export function buildNodeParams(fieldName: string, params: RangeFilterParams) { const paramsToMap = _.pick(params, 'gt', 'lt', 'gte', 'lte', 'format'); @@ -33,7 +33,7 @@ export function buildNodeParams(fieldName: string, params: RangeFilterParams) { export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config: Record = {}, context: Record = {} ) { diff --git a/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts b/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts index 4002a36648f04..7dac1262d5062 100644 --- a/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts +++ b/src/plugins/data/common/es_query/kuery/functions/utils/get_fields.ts @@ -8,10 +8,10 @@ import * as literal from '../../node_types/literal'; import * as wildcard from '../../node_types/wildcard'; -import { KueryNode, IIndexPattern } from '../../../..'; +import { KueryNode, IndexPatternBase } from '../../../..'; import { LiteralTypeBuildNode } from '../../node_types/types'; -export function getFields(node: KueryNode, indexPattern?: IIndexPattern) { +export function getFields(node: KueryNode, indexPattern?: IndexPatternBase) { if (!indexPattern) return []; if (node.type === 'literal') { const fieldName = literal.toElasticsearchQuery(node as LiteralTypeBuildNode); diff --git a/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts b/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts index e623579226861..644791637aa70 100644 --- a/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts +++ b/src/plugins/data/common/es_query/kuery/functions/utils/get_full_field_name_node.ts @@ -7,11 +7,11 @@ */ import { getFields } from './get_fields'; -import { IIndexPattern, IFieldType, KueryNode } from '../../../..'; +import { IndexPatternBase, IFieldType, KueryNode } from '../../../..'; export function getFullFieldNameNode( rootNameNode: any, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, nestedPath?: string ): KueryNode { const fullFieldNameNode = { diff --git a/src/plugins/data/common/es_query/kuery/node_types/function.ts b/src/plugins/data/common/es_query/kuery/node_types/function.ts index b9b7379dfb23d..642089a101f31 100644 --- a/src/plugins/data/common/es_query/kuery/node_types/function.ts +++ b/src/plugins/data/common/es_query/kuery/node_types/function.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { functions } from '../functions'; -import { IIndexPattern, KueryNode } from '../../..'; +import { IndexPatternBase, KueryNode } from '../../..'; import { FunctionName, FunctionTypeBuildNode } from './types'; export function buildNode(functionName: FunctionName, ...args: any[]) { @@ -45,7 +45,7 @@ export function buildNodeWithArgumentNodes( export function toElasticsearchQuery( node: KueryNode, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ) { diff --git a/src/plugins/data/common/es_query/kuery/node_types/types.ts b/src/plugins/data/common/es_query/kuery/node_types/types.ts index b3247a0ad8dc2..ea8eb5e8a0618 100644 --- a/src/plugins/data/common/es_query/kuery/node_types/types.ts +++ b/src/plugins/data/common/es_query/kuery/node_types/types.ts @@ -11,8 +11,8 @@ */ import { JsonValue } from '@kbn/common-utils'; -import { IIndexPattern } from '../../../index_patterns'; import { KueryNode } from '..'; +import { IndexPatternBase } from '../..'; export type FunctionName = | 'is' @@ -30,7 +30,7 @@ interface FunctionType { buildNodeWithArgumentNodes: (functionName: FunctionName, args: any[]) => FunctionTypeBuildNode; toElasticsearchQuery: ( node: any, - indexPattern?: IIndexPattern, + indexPattern?: IndexPatternBase, config?: Record, context?: Record ) => JsonValue; diff --git a/src/plugins/data/common/field_formats/converters/string.ts b/src/plugins/data/common/field_formats/converters/string.ts index ec92d75910522..64367df5d90dd 100644 --- a/src/plugins/data/common/field_formats/converters/string.ts +++ b/src/plugins/data/common/field_formats/converters/string.ts @@ -13,6 +13,10 @@ import { FieldFormat } from '../field_format'; import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; import { shortenDottedString } from '../../utils'; +export const emptyLabel = i18n.translate('data.fieldFormats.string.emptyLabel', { + defaultMessage: '(empty)', +}); + const TRANSFORM_OPTIONS = [ { kind: false, @@ -103,6 +107,9 @@ export class StringFormat extends FieldFormat { } textConvert: TextContextTypeConvert = (val) => { + if (val === '') { + return emptyLabel; + } switch (this.param('transform')) { case 'lower': return String(val).toLowerCase(); diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index e67e72f295b8e..cecf3b8c07d1a 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { PublicMethodsOf } from '@kbn/utility-types'; -import { SavedObjectsClientCommon } from '../..'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE, SavedObjectsClientCommon } from '../..'; import { createIndexPatternCache } from '.'; import type { RuntimeField } from '../types'; @@ -38,7 +38,6 @@ import { DuplicateIndexPatternError } from '../errors'; import { castEsToKbnFieldTypeName } from '../../kbn_field_types'; const MAX_ATTEMPTS_TO_RESOLVE_CONFLICTS = 3; -const savedObjectType = 'index-pattern'; export interface IndexPatternSavedObjectAttrs { title: string; @@ -94,7 +93,7 @@ export class IndexPatternsService { */ private async refreshSavedObjectsCache() { const so = await this.savedObjectsClient.find({ - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, fields: ['title'], perPage: 10000, }); @@ -137,7 +136,7 @@ export class IndexPatternsService { */ find = async (search: string, size: number = 10): Promise => { const savedObjects = await this.savedObjectsClient.find({ - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, fields: ['title'], search, searchFields: ['title'], @@ -395,12 +394,16 @@ export class IndexPatternsService { private getSavedObjectAndInit = async (id: string): Promise => { const savedObject = await this.savedObjectsClient.get( - savedObjectType, + INDEX_PATTERN_SAVED_OBJECT_TYPE, id ); if (!savedObject.version) { - throw new SavedObjectNotFound(savedObjectType, id, 'management/kibana/indexPatterns'); + throw new SavedObjectNotFound( + INDEX_PATTERN_SAVED_OBJECT_TYPE, + id, + 'management/kibana/indexPatterns' + ); } return this.initFromSavedObject(savedObject); @@ -546,7 +549,7 @@ export class IndexPatternsService { const body = indexPattern.getAsSavedObjectBody(); const response: SavedObject = (await this.savedObjectsClient.create( - savedObjectType, + INDEX_PATTERN_SAVED_OBJECT_TYPE, body, { id: indexPattern.id, @@ -587,7 +590,9 @@ export class IndexPatternsService { }); return this.savedObjectsClient - .update(savedObjectType, indexPattern.id, body, { version: indexPattern.version }) + .update(INDEX_PATTERN_SAVED_OBJECT_TYPE, indexPattern.id, body, { + version: indexPattern.version, + }) .then((resp) => { indexPattern.id = resp.id; indexPattern.version = resp.version; @@ -655,7 +660,7 @@ export class IndexPatternsService { */ async delete(indexPatternId: string) { this.indexPatternCache.clear(indexPatternId); - return this.savedObjectsClient.delete('index-pattern', indexPatternId); + return this.savedObjectsClient.delete(INDEX_PATTERN_SAVED_OBJECT_TYPE, indexPatternId); } } diff --git a/src/plugins/data/common/index_patterns/lib/get_title.ts b/src/plugins/data/common/index_patterns/lib/get_title.ts index 2dd122092f688..69afad486a745 100644 --- a/src/plugins/data/common/index_patterns/lib/get_title.ts +++ b/src/plugins/data/common/index_patterns/lib/get_title.ts @@ -7,12 +7,16 @@ */ import { SavedObjectsClientContract, SimpleSavedObject } from '../../../../../core/public'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../constants'; export async function getTitle( client: SavedObjectsClientContract, indexPatternId: string ): Promise> { - const savedObject = (await client.get('index-pattern', indexPatternId)) as SimpleSavedObject; + const savedObject = (await client.get( + INDEX_PATTERN_SAVED_OBJECT_TYPE, + indexPatternId + )) as SimpleSavedObject; if (savedObject.error) { throw new Error(`Unable to get index-pattern title: ${savedObject.error.message}`); diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index 07aa8967b905e..a88f029c0c7cd 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -9,6 +9,7 @@ import type { estypes } from '@elastic/elasticsearch'; import { ToastInputFields, ErrorToastOptions } from 'src/core/public/notifications'; // eslint-disable-next-line import type { SavedObject } from 'src/core/server'; +import type { IndexPatternBase } from '../es_query'; import { IFieldType } from './fields'; import { RUNTIME_FIELD_TYPES } from './constants'; import { SerializedFieldFormat } from '../../../expressions/common'; @@ -29,10 +30,8 @@ export interface RuntimeField { * IIndexPattern allows for an IndexPattern OR an index pattern saved object * Use IndexPattern or IndexPatternSpec instead */ -export interface IIndexPattern { - fields: IFieldType[]; +export interface IIndexPattern extends IndexPatternBase { title: string; - id?: string; /** * Type is used for identifying rollup indices, otherwise left undefined */ diff --git a/src/plugins/data/common/index_patterns/utils.ts b/src/plugins/data/common/index_patterns/utils.ts index 941ad3c47066b..925f646b83bb7 100644 --- a/src/plugins/data/common/index_patterns/utils.ts +++ b/src/plugins/data/common/index_patterns/utils.ts @@ -9,6 +9,8 @@ import type { IndexPatternSavedObjectAttrs } from './index_patterns'; import type { SavedObjectsClientCommon } from '../types'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../constants'; + /** * Returns an object matching a given title * @@ -19,7 +21,7 @@ import type { SavedObjectsClientCommon } from '../types'; export async function findByTitle(client: SavedObjectsClientCommon, title: string) { if (title) { const savedObjects = await client.find({ - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, perPage: 10, search: `"${title}"`, searchFields: ['title'], diff --git a/src/plugins/data/common/search/aggs/utils/parse_time_shift.ts b/src/plugins/data/common/search/aggs/utils/parse_time_shift.ts index 4d8ee0f889173..91379ea054de3 100644 --- a/src/plugins/data/common/search/aggs/utils/parse_time_shift.ts +++ b/src/plugins/data/common/search/aggs/utils/parse_time_shift.ts @@ -20,7 +20,7 @@ export const parseTimeShift = (val: string): moment.Duration | 'previous' | 'inv if (trimmedVal === 'previous') { return 'previous'; } - const [, amount, unit] = trimmedVal.match(/^(\d+)(\w)$/) || []; + const [, amount, unit] = trimmedVal.match(/^(\d+)\s*(\w)$/) || []; const parsedAmount = Number(amount); if (Number.isNaN(parsedAmount) || !allowedUnits.includes(unit as AllowedUnit)) { return 'invalid'; diff --git a/src/plugins/data/common/search/expressions/phrase_filter.test.ts b/src/plugins/data/common/search/expressions/phrase_filter.test.ts index 39bd907513a0d..a61cc0bfd68ab 100644 --- a/src/plugins/data/common/search/expressions/phrase_filter.test.ts +++ b/src/plugins/data/common/search/expressions/phrase_filter.test.ts @@ -32,7 +32,6 @@ describe('interpreter/functions#phraseFilter', () => { "something", ], "type": "phrases", - "value": "test, something", }, "query": Object { "bool": Object { diff --git a/src/plugins/data/common/search/search_source/extract_references.ts b/src/plugins/data/common/search/search_source/extract_references.ts index 1b4d1732a5e37..b63b8ed1cfee2 100644 --- a/src/plugins/data/common/search/search_source/extract_references.ts +++ b/src/plugins/data/common/search/search_source/extract_references.ts @@ -10,6 +10,8 @@ import { SavedObjectReference } from 'src/core/types'; import { Filter } from '../../es_query/filters'; import { SearchSourceFields } from './types'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../constants'; + export const extractReferences = ( state: SearchSourceFields ): [SearchSourceFields & { indexRefName?: string }, SavedObjectReference[]] => { @@ -20,7 +22,7 @@ export const extractReferences = ( const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; references.push({ name: refName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: indexId, }); searchSourceFields = { @@ -40,7 +42,7 @@ export const extractReferences = ( const refName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; references.push({ name: refName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: filterRow.meta.index, }); return { diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index d1890ec97df4e..c5cf3f9f09e6c 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -65,6 +65,11 @@ export interface IKibanaSearchResponse { */ isPartial?: boolean; + /** + * Indicates whether the results returned are from the async-search index + */ + isRestored?: boolean; + /** * The raw response returned by the internal search method (usually the raw ES response) */ diff --git a/src/plugins/data/config.ts b/src/plugins/data/config.ts index 9306b64019bbc..1b7bfbc09ad16 100644 --- a/src/plugins/data/config.ts +++ b/src/plugins/data/config.ts @@ -44,10 +44,20 @@ export const searchSessionsConfigSchema = schema.object({ */ pageSize: schema.number({ defaultValue: 100 }), /** - * trackingInterval controls how often we track search session objects progress + * trackingInterval controls how often we track persisted search session objects progress */ trackingInterval: schema.duration({ defaultValue: '10s' }), + /** + * cleanupInterval controls how often we track non-persisted search session objects for cleanup + */ + cleanupInterval: schema.duration({ defaultValue: '60s' }), + + /** + * expireInterval controls how often we track persisted search session objects for expiration + */ + expireInterval: schema.duration({ defaultValue: '60m' }), + /** * monitoringTaskTimeout controls for how long task manager waits for search session monitoring task to complete before considering it timed out, * If tasks timeouts it receives cancel signal and next task starts in "trackingInterval" time diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 078dd3a9b7c5a..e9e50ebfaf138 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -23,7 +23,6 @@ import { disableFilter, FILTERS, FilterStateStore, - getDisplayValueFromFilter, getPhraseFilterField, getPhraseFilterValue, isExistsFilter, @@ -43,6 +42,7 @@ import { FilterLabel } from './ui'; import { FilterItem } from './ui/filter_bar'; import { + getDisplayValueFromFilter, generateFilters, onlyDisabledFiltersChanged, changeTimeFilter, @@ -268,6 +268,7 @@ export { IndexPatternSpec, IndexPatternLoadExpressionFunctionDefinition, fieldList, + INDEX_PATTERN_SAVED_OBJECT_TYPE, } from '../common'; export { DuplicateIndexPatternError } from '../common/index_patterns/errors'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 4d9c69b137a3e..6a49fab0e33ff 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -808,11 +808,11 @@ export const esFilters: { FILTERS: typeof FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isPhraseFilter: (filter: any) => filter is import("../common").PhraseFilter; isExistsFilter: (filter: any) => filter is import("../common").ExistsFilter; isPhrasesFilter: (filter: any) => filter is import("../common").PhrasesFilter; @@ -858,7 +858,7 @@ export const esFilters: { export const esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; }; // Warning: (ae-missing-release-tag) "esQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -867,7 +867,7 @@ export const esKuery: { export const esQuery: { buildEsQuery: typeof buildEsQuery; getEsQueryConfig: typeof getEsQueryConfig; - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; @@ -1286,22 +1286,19 @@ export interface IFieldType { visualizable?: boolean; } +// Warning: (ae-forgotten-export) The symbol "IndexPatternBase" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "IIndexPattern" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) -export interface IIndexPattern { +export interface IIndexPattern extends IndexPatternBase { // Warning: (ae-forgotten-export) The symbol "SerializedFieldFormat" needs to be exported by the entry point index.d.ts // // (undocumented) fieldFormatMap?: Record | undefined>; - // (undocumented) - fields: IFieldType[]; getFormatterForField?: (field: IndexPatternField | IndexPatternField['spec'] | IFieldType) => FieldFormat; // (undocumented) getTimeField?(): IFieldType | undefined; // (undocumented) - id?: string; - // (undocumented) timeFieldName?: string; // (undocumented) title: string; @@ -1351,6 +1348,7 @@ export interface IKibanaSearchRequest { export interface IKibanaSearchResponse { id?: string; isPartial?: boolean; + isRestored?: boolean; isRunning?: boolean; loaded?: number; rawResponse: RawResponse; @@ -1363,6 +1361,9 @@ export interface IKibanaSearchResponse { // @public (undocumented) export type IMetricAggType = MetricAggType; +// @public (undocumented) +export const INDEX_PATTERN_SAVED_OBJECT_TYPE = "index-pattern"; + // Warning: (ae-missing-release-tag) "IndexPattern" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2730,13 +2731,13 @@ export interface WaitUntilNextSessionCompletesOptions { // Warnings were encountered during analysis: // -// src/plugins/data/common/es_query/filters/exists_filter.ts:19:3 - (ae-forgotten-export) The symbol "ExistsFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/exists_filter.ts:20:3 - (ae-forgotten-export) The symbol "FilterExistsProperty" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/exists_filter.ts:20:3 - (ae-forgotten-export) The symbol "ExistsFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/exists_filter.ts:21:3 - (ae-forgotten-export) The symbol "FilterExistsProperty" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/match_all_filter.ts:17:3 - (ae-forgotten-export) The symbol "MatchAllFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:43:3 - (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:44:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/phrase_filter.ts:22:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/es_query/filters/phrases_filter.ts:20:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/phrase_filter.ts:23:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/phrases_filter.ts:21:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:65:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts @@ -2774,20 +2775,20 @@ export interface WaitUntilNextSessionCompletesOptions { // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:431:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:432:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:435:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:429:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:432:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:433:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:436: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:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/filter_manager/index.ts b/src/plugins/data/public/query/filter_manager/index.ts index 327b9763541ac..55dba640b07b6 100644 --- a/src/plugins/data/public/query/filter_manager/index.ts +++ b/src/plugins/data/public/query/filter_manager/index.ts @@ -11,3 +11,5 @@ export { FilterManager } from './filter_manager'; export { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; export { onlyDisabledFiltersChanged } from './lib/only_disabled'; export { generateFilters } from './lib/generate_filters'; +export { getDisplayValueFromFilter } from './lib/get_display_value'; +export { getIndexPatternFromFilter } from './lib/get_index_pattern_from_filter'; diff --git a/src/plugins/data/public/query/filter_manager/lib/get_display_value.test.ts b/src/plugins/data/public/query/filter_manager/lib/get_display_value.test.ts new file mode 100644 index 0000000000000..48e1007534769 --- /dev/null +++ b/src/plugins/data/public/query/filter_manager/lib/get_display_value.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { stubIndexPattern, phraseFilter } from 'src/plugins/data/common/stubs'; +import { getDisplayValueFromFilter } from './get_display_value'; + +describe('getDisplayValueFromFilter', () => { + it('returns the value if string', () => { + phraseFilter.meta.value = 'abc'; + const displayValue = getDisplayValueFromFilter(phraseFilter, [stubIndexPattern]); + expect(displayValue).toBe('abc'); + }); + + it('returns the value if undefined', () => { + phraseFilter.meta.value = undefined; + const displayValue = getDisplayValueFromFilter(phraseFilter, [stubIndexPattern]); + expect(displayValue).toBe(''); + }); + + it('calls the value function if proivided', () => { + // The type of value currently doesn't match how it's used. Refactor needed. + phraseFilter.meta.value = jest.fn((x) => { + return 'abc'; + }) as any; + const displayValue = getDisplayValueFromFilter(phraseFilter, [stubIndexPattern]); + expect(displayValue).toBe('abc'); + expect(phraseFilter.meta.value).toHaveBeenCalledWith(undefined); + }); + + it('calls the value function if proivided, with formatter', () => { + stubIndexPattern.getFormatterForField = jest.fn().mockReturnValue('banana'); + phraseFilter.meta.value = jest.fn((x) => { + return x + 'abc'; + }) as any; + const displayValue = getDisplayValueFromFilter(phraseFilter, [stubIndexPattern]); + expect(stubIndexPattern.getFormatterForField).toHaveBeenCalledTimes(1); + expect(phraseFilter.meta.value).toHaveBeenCalledWith('banana'); + expect(displayValue).toBe('bananaabc'); + }); +}); diff --git a/src/plugins/data/common/es_query/filters/get_display_value.ts b/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts similarity index 77% rename from src/plugins/data/common/es_query/filters/get_display_value.ts rename to src/plugins/data/public/query/filter_manager/lib/get_display_value.ts index ee719843ae879..1ccfaacb24e4b 100644 --- a/src/plugins/data/common/es_query/filters/get_display_value.ts +++ b/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts @@ -7,9 +7,8 @@ */ import { i18n } from '@kbn/i18n'; -import { IIndexPattern } from '../..'; +import { Filter, IIndexPattern } from '../../../../common'; import { getIndexPatternFromFilter } from './get_index_pattern_from_filter'; -import { Filter } from '../filters'; function getValueFormatter(indexPattern?: IIndexPattern, key?: string) { // checking getFormatterForField exists because there is at least once case where an index pattern @@ -29,11 +28,14 @@ function getValueFormatter(indexPattern?: IIndexPattern, key?: string) { } export function getDisplayValueFromFilter(filter: Filter, indexPatterns: IIndexPattern[]): string { - if (typeof filter.meta.value === 'function') { + const { key, value } = filter.meta; + if (typeof value === 'function') { const indexPattern = getIndexPatternFromFilter(filter, indexPatterns); - const valueFormatter: any = getValueFormatter(indexPattern, filter.meta.key); - return (filter.meta.value as any)(valueFormatter); + const valueFormatter = getValueFormatter(indexPattern, key); + // TODO: distinguish between FilterMeta which is serializable to mapped FilterMeta + // Where value can be a function. + return (value as any)(valueFormatter); } else { - return filter.meta.value || ''; + return value || ''; } } diff --git a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.test.ts b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.test.ts similarity index 100% rename from src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.test.ts rename to src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.test.ts diff --git a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts similarity index 88% rename from src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts rename to src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts index bceeb5f2793ec..7a2ce29102e51 100644 --- a/src/plugins/data/common/es_query/filters/get_index_pattern_from_filter.ts +++ b/src/plugins/data/public/query/filter_manager/lib/get_index_pattern_from_filter.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { Filter } from '../filters'; -import { IIndexPattern } from '../..'; +import { Filter, IIndexPattern } from '../../../../common'; export function getIndexPatternFromFilter( filter: Filter, diff --git a/src/plugins/data/public/query/filter_manager/lib/mappers/map_phrases.ts b/src/plugins/data/public/query/filter_manager/lib/mappers/map_phrases.ts index bfd528264b00f..5601dd66e5206 100644 --- a/src/plugins/data/public/query/filter_manager/lib/mappers/map_phrases.ts +++ b/src/plugins/data/public/query/filter_manager/lib/mappers/map_phrases.ts @@ -6,14 +6,29 @@ * Side Public License, v 1. */ -import { Filter, isPhrasesFilter } from '../../../../../common'; +import { Filter, FilterValueFormatter, isPhrasesFilter } from '../../../../../common'; + +const getFormattedValueFn = (params: any) => { + return (formatter?: FilterValueFormatter) => { + return params + .map((v: any) => { + return formatter ? formatter.convert(v) : v; + }) + .join(', '); + }; +}; export const mapPhrases = (filter: Filter) => { if (!isPhrasesFilter(filter)) { throw filter; } - const { type, key, value, params } = filter.meta; + const { type, key, params } = filter.meta; - return { type, key, value, params }; + return { + type, + key, + value: getFormattedValueFn(params), + params, + }; }; diff --git a/src/plugins/data/public/search/errors/index.ts b/src/plugins/data/public/search/errors/index.ts index 82c9e04b79798..fcdea8dec1c2e 100644 --- a/src/plugins/data/public/search/errors/index.ts +++ b/src/plugins/data/public/search/errors/index.ts @@ -12,3 +12,4 @@ export * from './timeout_error'; export * from './utils'; export * from './types'; export * from './http_error'; +export * from './search_session_incomplete_warning'; diff --git a/src/plugins/data/public/search/errors/search_session_incomplete_warning.tsx b/src/plugins/data/public/search/errors/search_session_incomplete_warning.tsx new file mode 100644 index 0000000000000..c5c5c37f31cf8 --- /dev/null +++ b/src/plugins/data/public/search/errors/search_session_incomplete_warning.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const SearchSessionIncompleteWarning = (docLinks: CoreStart['docLinks']) => ( + <> + + It needs more time to fully render. You can wait here or come back to it later. + + + + + + + +); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts index fe66d4b6e9937..155638250a2a4 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -29,6 +29,12 @@ jest.mock('./utils', () => ({ }), })); +jest.mock('../errors/search_session_incomplete_warning', () => ({ + SearchSessionIncompleteWarning: jest.fn(), +})); + +import { SearchSessionIncompleteWarning } from '../errors/search_session_incomplete_warning'; + let searchInterceptor: SearchInterceptor; let mockCoreSetup: MockedKeys; let bfetchSetup: jest.Mocked; @@ -508,6 +514,7 @@ describe('SearchInterceptor', () => { } : null ); + sessionServiceMock.isRestore.mockReturnValue(!!opts?.isRestore); fetchMock.mockResolvedValue({ result: 200 }); }; @@ -562,6 +569,92 @@ describe('SearchInterceptor', () => { (sessionService as jest.Mocked).getSearchOptions ).toHaveBeenCalledWith(sessionId); }); + + test('should not show warning if a search is available during restore', async () => { + setup({ + isRestore: true, + isStored: true, + sessionId: '123', + }); + + const responses = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + isRestored: true, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search( + {}, + { + sessionId: '123', + } + ); + response.subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(SearchSessionIncompleteWarning).toBeCalledTimes(0); + }); + + test('should show warning once if a search is not available during restore', async () => { + setup({ + isRestore: true, + isStored: true, + sessionId: '123', + }); + + const responses = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + isRestored: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + mockFetchImplementation(responses); + + searchInterceptor + .search( + {}, + { + sessionId: '123', + } + ) + .subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(SearchSessionIncompleteWarning).toBeCalledTimes(1); + + searchInterceptor + .search( + {}, + { + sessionId: '123', + } + ) + .subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(SearchSessionIncompleteWarning).toBeCalledTimes(1); + }); }); describe('Session tracking', () => { diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts index 57b156a9b3c00..e0e1df65101c7 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -43,6 +43,7 @@ import { PainlessError, SearchTimeoutError, TimeoutErrorMode, + SearchSessionIncompleteWarning, } from '../errors'; import { toMountPoint } from '../../../../kibana_react/public'; import { AbortError, KibanaServerError } from '../../../../kibana_utils/public'; @@ -82,6 +83,7 @@ export class SearchInterceptor { * @internal */ private application!: CoreStart['application']; + private docLinks!: CoreStart['docLinks']; private batchedFetch!: BatchedFunc< { request: IKibanaSearchRequest; options: ISearchOptionsSerializable }, IKibanaSearchResponse @@ -95,6 +97,7 @@ export class SearchInterceptor { this.deps.startServices.then(([coreStart]) => { this.application = coreStart.application; + this.docLinks = coreStart.docLinks; }); this.batchedFetch = deps.bfetch.batchedFunction({ @@ -345,6 +348,11 @@ export class SearchInterceptor { this.handleSearchError(e, searchOptions, searchAbortController.isTimeout()) ); }), + tap((response) => { + if (this.deps.session.isRestore() && response.isRestored === false) { + this.showRestoreWarning(this.deps.session.getSessionId()); + } + }), finalize(() => { this.pendingCount$.next(this.pendingCount$.getValue() - 1); if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) { @@ -371,6 +379,25 @@ export class SearchInterceptor { } ); + private showRestoreWarningToast = (sessionId?: string) => { + this.deps.toasts.addWarning( + { + title: 'Your search session is still running', + text: toMountPoint(SearchSessionIncompleteWarning(this.docLinks)), + }, + { + toastLifeTimeMs: 60000, + } + ); + }; + + private showRestoreWarning = memoize( + this.showRestoreWarningToast, + (_: SearchTimeoutError, sessionId: string) => { + return sessionId; + } + ); + /** * Show one error notification per session. * @internal diff --git a/src/plugins/data/public/search/session/session_service.test.ts b/src/plugins/data/public/search/session/session_service.test.ts index 39680c4948366..7f388a29cd454 100644 --- a/src/plugins/data/public/search/session/session_service.test.ts +++ b/src/plugins/data/public/search/session/session_service.test.ts @@ -98,6 +98,14 @@ describe('Session service', () => { expect(nowProvider.reset).toHaveBeenCalled(); }); + it("Can clear other apps' session", async () => { + sessionService.start(); + expect(sessionService.getSessionId()).not.toBeUndefined(); + currentAppId$.next('change'); + sessionService.clear(); + expect(sessionService.getSessionId()).toBeUndefined(); + }); + it("Can start a new session in case there is other apps' stale session", async () => { const s1 = sessionService.start(); expect(sessionService.getSessionId()).not.toBeUndefined(); diff --git a/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx b/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx index 23de8327ce1f1..9cc9af04409f1 100644 --- a/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx +++ b/src/plugins/data/public/ui/apply_filters/apply_filter_popover_content.tsx @@ -20,9 +20,9 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import { IIndexPattern } from '../..'; -import { getDisplayValueFromFilter, Filter } from '../../../common'; +import { Filter } from '../../../common'; import { FilterLabel } from '../filter_bar'; -import { mapAndFlattenFilters } from '../../query'; +import { mapAndFlattenFilters, getDisplayValueFromFilter } from '../../query'; interface Props { filters: Filter[]; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx index 2b8978a125bca..734161ea87232 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx @@ -37,10 +37,10 @@ import { Operator } from './lib/filter_operators'; import { PhraseValueInput } from './phrase_value_input'; import { PhrasesValuesInput } from './phrases_values_input'; import { RangeValueInput } from './range_value_input'; +import { getIndexPatternFromFilter } from '../../../query'; import { IIndexPattern, IFieldType } from '../../..'; import { Filter, - getIndexPatternFromFilter, FieldFilter, buildFilter, buildCustomFilter, diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx index 9e5090f945182..09e0571c2a870 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -14,14 +14,13 @@ import { IUiSettingsClient } from 'src/core/public'; import { FilterEditor } from './filter_editor'; import { FilterView } from './filter_view'; import { IIndexPattern } from '../..'; +import { getDisplayValueFromFilter, getIndexPatternFromFilter } from '../../query'; import { Filter, isFilterPinned, - getDisplayValueFromFilter, toggleFilterNegated, toggleFilterPinned, toggleFilterDisabled, - getIndexPatternFromFilter, } from '../../../common'; import { getIndexPatterns } from '../../services'; diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index a0a7e54d27532..0ab3f8a4e3466 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -176,27 +176,27 @@ exports[`Inspector Data View component should render empty state 1`] = `
    + +

    + + No data available + +

    +
    - -

    - - No data available - -

    -
    diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 0764f4f441e42..143400a2c09d3 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -117,7 +117,12 @@ export const fieldFormats = { HistogramFormat, }; -export { IFieldFormatsRegistry, FieldFormatsGetConfigFn, FieldFormatConfig } from '../common'; +export { + IFieldFormatsRegistry, + FieldFormatsGetConfigFn, + FieldFormatConfig, + INDEX_PATTERN_SAVED_OBJECT_TYPE, +} from '../common'; /* * Index patterns: @@ -238,6 +243,7 @@ export { DataRequestHandlerContext, AsyncSearchResponse, AsyncSearchStatusResponse, + NoSearchIdInSessionError, } from './search'; // Search namespace diff --git a/src/plugins/data/server/index_patterns/utils.ts b/src/plugins/data/server/index_patterns/utils.ts index bb16be23edc7d..7f1a953c482d0 100644 --- a/src/plugins/data/server/index_patterns/utils.ts +++ b/src/plugins/data/server/index_patterns/utils.ts @@ -7,7 +7,12 @@ */ import { SavedObjectsClientContract } from 'kibana/server'; -import { IFieldType, IndexPatternAttributes, SavedObject } from '../../common'; +import { + IFieldType, + INDEX_PATTERN_SAVED_OBJECT_TYPE, + IndexPatternAttributes, + SavedObject, +} from '../../common'; export const getFieldByName = ( fieldName: string, @@ -24,7 +29,7 @@ export const findIndexPatternById = async ( index: string ): Promise | undefined> => { const savedObjectsResponse = await savedObjectsClient.find({ - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, fields: ['fields'], search: `"${index}"`, searchFields: ['title'], diff --git a/src/plugins/data/server/saved_objects/index_patterns.ts b/src/plugins/data/server/saved_objects/index_patterns.ts index f570e239c3c64..a809f2ce73e1b 100644 --- a/src/plugins/data/server/saved_objects/index_patterns.ts +++ b/src/plugins/data/server/saved_objects/index_patterns.ts @@ -6,11 +6,12 @@ * Side Public License, v 1. */ -import { SavedObjectsType } from 'kibana/server'; +import type { SavedObjectsType } from 'kibana/server'; import { indexPatternSavedObjectTypeMigrations } from './index_pattern_migrations'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../common'; export const indexPatternSavedObjectType: SavedObjectsType = { - name: 'index-pattern', + name: INDEX_PATTERN_SAVED_OBJECT_TYPE, hidden: false, namespaceType: 'single', management: { diff --git a/packages/kbn-interpreter/src/common/index.d.ts b/src/plugins/data/server/search/errors/no_search_id_in_session.ts similarity index 62% rename from packages/kbn-interpreter/src/common/index.d.ts rename to src/plugins/data/server/search/errors/no_search_id_in_session.ts index 6f54d07590973..b291df1cee5ba 100644 --- a/packages/kbn-interpreter/src/common/index.d.ts +++ b/src/plugins/data/server/search/errors/no_search_id_in_session.ts @@ -6,7 +6,10 @@ * Side Public License, v 1. */ -export { Registry } from './lib/registry'; +import { KbnError } from '../../../../kibana_utils/common'; -export { fromExpression, toExpression, Ast, ExpressionFunctionAST } from './lib/ast'; -export { getType } from './lib/get_type'; +export class NoSearchIdInSessionError extends KbnError { + constructor() { + super('No search ID in this session matching the given search request'); + } +} diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts index 812f3171aef99..b9affe96ea2dd 100644 --- a/src/plugins/data/server/search/index.ts +++ b/src/plugins/data/server/search/index.ts @@ -13,3 +13,4 @@ export * from './strategies/eql_search'; export { usageProvider, SearchUsage, searchUsageObserver } from './collectors'; export * from './aggs'; export * from './session'; +export * from './errors/no_search_id_in_session'; diff --git a/src/plugins/data/server/search/search_service.test.ts b/src/plugins/data/server/search/search_service.test.ts index 52ee8e60a5b26..314cb2c3acbf8 100644 --- a/src/plugins/data/server/search/search_service.test.ts +++ b/src/plugins/data/server/search/search_service.test.ts @@ -25,6 +25,7 @@ import { ISearchSessionService, ISearchStart, ISearchStrategy, + NoSearchIdInSessionError, } from '.'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { expressionsPluginMock } from '../../../expressions/public/mocks'; @@ -175,6 +176,22 @@ describe('Search service', () => { expect(request).toStrictEqual({ ...searchRequest, id: 'my_id' }); }); + it('searches even if id is not found in session during restore', async () => { + const searchRequest = { params: {} }; + const options = { sessionId, isStored: true, isRestore: true }; + + mockSessionClient.getId = jest.fn().mockImplementation(() => { + throw new NoSearchIdInSessionError(); + }); + + const res = await mockScopedClient.search(searchRequest, options).toPromise(); + + const [request, callOptions] = mockStrategy.search.mock.calls[0]; + expect(callOptions).toBe(options); + expect(request).toStrictEqual({ ...searchRequest }); + expect(res.isRestored).toBe(false); + }); + it('does not fail if `trackId` throws', async () => { const searchRequest = { params: {} }; const options = { sessionId, isStored: false, isRestore: false }; diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index a651d7b3bf105..00dffefa5e3a6 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -19,7 +19,7 @@ import { SharedGlobalConfig, StartServicesAccessor, } from 'src/core/server'; -import { first, switchMap, tap } from 'rxjs/operators'; +import { first, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import type { @@ -80,6 +80,7 @@ import { registerBsearchRoute } from './routes/bsearch'; import { getKibanaContext } from './expressions/kibana_context'; import { enhancedEsSearchStrategyProvider } from './strategies/ese_search'; import { eqlSearchStrategyProvider } from './strategies/eql_search'; +import { NoSearchIdInSessionError } from './errors/no_search_id_in_session'; type StrategyMap = Record>; @@ -287,24 +288,48 @@ export class SearchService implements Plugin { options.strategy ); - const getSearchRequest = async () => - !options.sessionId || !options.isRestore || request.id - ? request - : { + const getSearchRequest = async () => { + if (!options.sessionId || !options.isRestore || request.id) { + return request; + } else { + try { + const id = await deps.searchSessionsClient.getId(request, options); + this.logger.debug(`Found search session id for request ${id}`); + return { ...request, - id: await deps.searchSessionsClient.getId(request, options), + id, }; + } catch (e) { + if (e instanceof NoSearchIdInSessionError) { + this.logger.debug('Ignoring missing search ID'); + return request; + } else { + throw e; + } + } + } + }; - return from(getSearchRequest()).pipe( + const searchRequest$ = from(getSearchRequest()); + const search$ = searchRequest$.pipe( switchMap((searchRequest) => strategy.search(searchRequest, options, deps)), - tap((response) => { - if (!options.sessionId || !response.id || options.isRestore) return; + withLatestFrom(searchRequest$), + tap(([response, requestWithId]) => { + if (!options.sessionId || !response.id || (options.isRestore && requestWithId.id)) return; // intentionally swallow tracking error, as it shouldn't fail the search deps.searchSessionsClient.trackId(request, response.id, options).catch((trackErr) => { this.logger.error(trackErr); }); + }), + map(([response, requestWithId]) => { + return { + ...response, + isRestored: !!requestWithId.id, + }; }) ); + + return search$; } catch (e) { return throwError(e); } diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index c2b533bc42dc6..86aaf64dea852 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -447,11 +447,11 @@ export const esFilters: { buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; buildCustomFilter: typeof buildCustomFilter; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IIndexPattern) => import("../common").ExistsFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; buildFilter: typeof buildFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IIndexPattern) => import("../common").PhraseFilter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IIndexPattern) => import("../common").PhrasesFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isFilterDisabled: (filter: import("../common").Filter) => boolean; }; @@ -461,14 +461,14 @@ export const esFilters: { export const esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; }; // Warning: (ae-missing-release-tag) "esQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export const esQuery: { - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; @@ -745,6 +745,9 @@ export interface IFieldType { // @public (undocumented) export type IMetricAggType = MetricAggType; +// @public (undocumented) +export const INDEX_PATTERN_SAVED_OBJECT_TYPE = "index-pattern"; + // Warning: (ae-missing-release-tag) "IndexPattern" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1205,6 +1208,14 @@ export enum METRIC_TYPES { TOP_HITS = "top_hits" } +// Warning: (ae-forgotten-export) The symbol "KbnError" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "NoSearchIdInSessionError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class NoSearchIdInSessionError extends KbnError { + constructor(); +} + // Warning: (ae-missing-release-tag) "OptionedParamType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1535,20 +1546,20 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // 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:101:26 - (ae-forgotten-export) The symbol "HistogramFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:128:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:128:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:244:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:244:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:246:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:247:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:256:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:257:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:258:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:262:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:267:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:270:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:271:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:133:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:133:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:250:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:250:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:252:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:253:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:262:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:264:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:268:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:269:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:273:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:276:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:277:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:81: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:115:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx index 210313aac5366..f1967d5b10b3e 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx +++ b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import moment from 'moment'; import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { IUiSettingsClient } from 'kibana/public'; @@ -47,8 +47,21 @@ export function DiscoverChart({ stateContainer: GetStateReturn; timefield?: string; }) { + const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({ + element: null, + moveFocus: false, + }); + + useEffect(() => { + if (chartRef.current.moveFocus && chartRef.current.element) { + chartRef.current.element.focus(); + } + }, [state.hideChart]); + const toggleHideChart = useCallback(() => { - stateContainer.setAppState({ hideChart: !state.hideChart }); + const newHideChart = !state.hideChart; + stateContainer.setAppState({ hideChart: newHideChart }); + chartRef.current.moveFocus = !newHideChart; }, [state, stateContainer]); const onChangeInterval = useCallback( @@ -102,9 +115,7 @@ export function DiscoverChart({ { - toggleHideChart(); - }} + onClick={toggleHideChart} data-test-subj="discoverChartToggle" > {!state.hideChart @@ -122,6 +133,8 @@ export function DiscoverChart({ {!state.hideChart && chartData && (
    (chartRef.current.element = element)} + tabIndex={-1} aria-label={i18n.translate('discover.histogramOfFoundDocumentsAriaLabel', { defaultMessage: 'Histogram of found documents', })} diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx index 2fd394d98281b..57a9d518f838e 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx @@ -18,7 +18,6 @@ import { createSearchSourceMock } from '../../../../../../../data/common/search/ import { IndexPattern, IndexPatternAttributes } from '../../../../../../../data/common'; import { SavedObject } from '../../../../../../../../core/types'; import { indexPatternWithTimefieldMock } from '../../../../../__mocks__/index_pattern_with_timefield'; -import { DiscoverSearchSessionManager } from '../../services/discover_search_session'; import { GetStateReturn } from '../../services/discover_state'; import { DiscoverLayoutProps } from './types'; import { SavedSearchDataSubject } from '../../services/use_saved_search'; @@ -50,11 +49,12 @@ function getProps(indexPattern: IndexPattern): DiscoverLayoutProps { indexPattern, indexPatternList, navigateTo: jest.fn(), + onChangeIndexPattern: jest.fn(), + onUpdateQuery: jest.fn(), resetQuery: jest.fn(), savedSearch: savedSearchMock, savedSearchData$: savedSearch$, savedSearchRefetch$: new Subject(), - searchSessionManager: {} as DiscoverSearchSessionManager, searchSource: searchSourceMock, services, state: { columns: [] }, diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx index 0430614d413b6..a10674323e5cb 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx @@ -36,10 +36,8 @@ import { SortPairArr } from '../../../../angular/doc_table/lib/get_sort'; import { DOC_HIDE_TIME_COLUMN_SETTING, DOC_TABLE_LEGACY, - MODIFY_COLUMNS_ON_SWITCH, SAMPLE_SIZE_SETTING, SEARCH_FIELDS_FROM_SOURCE, - SORT_DEFAULT_ORDER_SETTING, } from '../../../../../../common'; import { popularizeField } from '../../../../helpers/popularize_field'; import { DocViewFilterFn } from '../../../../doc_views/doc_views_types'; @@ -52,7 +50,6 @@ import { InspectorSession } from '../../../../../../../inspector/public'; import { DiscoverUninitialized } from '../uninitialized/uninitialized'; import { SavedSearchDataMessage } from '../../services/use_saved_search'; import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns'; -import { getSwitchIndexPatternAppState } from '../../utils/get_switch_index_pattern_app_state'; import { FetchStatus } from '../../../../types'; const DocTableLegacyMemoized = React.memo(DocTableLegacy); @@ -72,26 +69,20 @@ export function DiscoverLayout({ indexPattern, indexPatternList, navigateTo, + onChangeIndexPattern, + onUpdateQuery, savedSearchRefetch$, resetQuery, savedSearchData$, savedSearch, - searchSessionManager, searchSource, services, state, stateContainer, }: DiscoverLayoutProps) { - const { - trackUiMetric, - capabilities, - indexPatterns, - data, - uiSettings: config, - filterManager, - } = services; + const { trackUiMetric, capabilities, indexPatterns, data, uiSettings, filterManager } = services; - const sampleSize = useMemo(() => config.get(SAMPLE_SIZE_SETTING), [config]); + const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING), [uiSettings]); const [expandedDoc, setExpandedDoc] = useState(undefined); const [inspectorSession, setInspectorSession] = useState(undefined); const scrollableDesktop = useRef(null); @@ -121,42 +112,21 @@ export function DiscoverLayout({ }; }, [savedSearchData$, fetchState]); - const isMobile = () => { - // collapse icon isn't displayed in mobile view, use it to detect which view is displayed - return collapseIcon && !collapseIcon.current; - }; + // collapse icon isn't displayed in mobile view, use it to detect which view is displayed + const isMobile = () => collapseIcon && !collapseIcon.current; const timeField = useMemo(() => { return indexPatternsUtils.isDefault(indexPattern) ? indexPattern.timeFieldName : undefined; }, [indexPattern]); const [isSidebarClosed, setIsSidebarClosed] = useState(false); - const isLegacy = useMemo(() => services.uiSettings.get(DOC_TABLE_LEGACY), [services]); - const useNewFieldsApi = useMemo(() => !services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [ - services, - ]); - - const unmappedFieldsConfig = useMemo( - () => ({ - showUnmappedFields: useNewFieldsApi, - }), - [useNewFieldsApi] - ); + const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); + const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); const resultState = useMemo(() => getResultState(fetchStatus, rows!), [fetchStatus, rows]); - const updateQuery = useCallback( - (_payload, isUpdate?: boolean) => { - if (isUpdate === false) { - searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); - savedSearchRefetch$.next(); - } - }, - [savedSearchRefetch$, searchSessionManager] - ); - const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useDataGridColumns({ capabilities, - config, + config: uiSettings, indexPattern, indexPatterns, setAppState: stateContainer.setAppState, @@ -243,42 +213,8 @@ export function DiscoverLayout({ const contentCentered = resultState === 'uninitialized'; const showTimeCol = useMemo( - () => !config.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName, - [config, indexPattern.timeFieldName] - ); - - const onChangeIndexPattern = useCallback( - async (id: string) => { - const nextIndexPattern = await indexPatterns.get(id); - if (nextIndexPattern && indexPattern) { - /** - * Without resetting the fetch state, e.g. a time column would be displayed when switching - * from a index pattern without to a index pattern with time filter for a brief moment - * That's because appState is updated before savedSearchData$ - * The following line of code catches this, but should be improved - */ - savedSearchData$.next({ rows: [], state: FetchStatus.LOADING, fieldCounts: {} }); - - const nextAppState = getSwitchIndexPatternAppState( - indexPattern, - nextIndexPattern, - state.columns || [], - (state.sort || []) as SortPairArr[], - config.get(MODIFY_COLUMNS_ON_SWITCH), - config.get(SORT_DEFAULT_ORDER_SETTING) - ); - stateContainer.setAppState(nextAppState); - } - }, - [ - config, - indexPattern, - indexPatterns, - savedSearchData$, - state.columns, - state.sort, - stateContainer, - ] + () => !uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName, + [uiSettings, indexPattern.timeFieldName] ); return ( @@ -294,7 +230,7 @@ export function DiscoverLayout({ searchSource={searchSource} services={services} stateContainer={stateContainer} - updateQuery={updateQuery} + updateQuery={onUpdateQuery} />

    @@ -316,7 +252,6 @@ export function DiscoverLayout({ state={state} isClosed={isSidebarClosed} trackUiMetric={trackUiMetric} - unmappedFieldsConfig={unmappedFieldsConfig} useNewFieldsApi={useNewFieldsApi} onEditRuntimeField={onEditRuntimeField} /> @@ -373,7 +308,7 @@ export function DiscoverLayout({ > >; - resetQuery: () => void; navigateTo: (url: string) => void; + onChangeIndexPattern: (id: string) => void; + onUpdateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; + resetQuery: () => void; savedSearch: SavedSearch; savedSearchData$: SavedSearchDataSubject; savedSearchRefetch$: SavedSearchRefetchSubject; - searchSessionManager: DiscoverSearchSessionManager; searchSource: ISearchSource; services: DiscoverServices; state: AppState; diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_field_details_footer.test.tsx.snap b/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_field_details_footer.test.tsx.snap deleted file mode 100644 index f976b961d8520..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_field_details_footer.test.tsx.snap +++ /dev/null @@ -1,705 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`discover sidebar field details footer renders properly 1`] = ` - - -
    - -
    - -
    - - - -
    -
    -
    -
    -
    -
    -
    -`; diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/change_indexpattern.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/change_indexpattern.test.tsx new file mode 100644 index 0000000000000..8c32942740a76 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/change_indexpattern.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { EuiSelectable } from '@elastic/eui'; +import { ShallowWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { shallowWithIntl } from '@kbn/test/jest'; +import { ChangeIndexPattern } from './change_indexpattern'; +import { indexPatternMock } from '../../../../../__mocks__/index_pattern'; +import { indexPatternWithTimefieldMock } from '../../../../../__mocks__/index_pattern_with_timefield'; +import { IndexPatternRef } from './types'; + +function getProps() { + return { + indexPatternId: indexPatternMock.id, + indexPatternRefs: [ + indexPatternMock as IndexPatternRef, + indexPatternWithTimefieldMock as IndexPatternRef, + ], + onChangeIndexPattern: jest.fn(), + trigger: { + label: indexPatternMock.title, + title: indexPatternMock.title, + 'data-test-subj': 'indexPattern-switch-link', + }, + }; +} + +function getIndexPatternPickerList(instance: ShallowWrapper) { + return instance.find(EuiSelectable).first(); +} + +function getIndexPatternPickerOptions(instance: ShallowWrapper) { + return getIndexPatternPickerList(instance).prop('options'); +} + +export function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { + const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( + instance + ).map((option: { label: string }) => + option.label === selectedLabel + ? { ...option, checked: 'on' } + : { ...option, checked: undefined } + ); + return getIndexPatternPickerList(instance).prop('onChange')!(options); +} + +describe('ChangeIndexPattern', () => { + test('switching index pattern to the same index pattern does not trigger onChangeIndexPattern', async () => { + const props = getProps(); + const comp = shallowWithIntl(); + await act(async () => { + selectIndexPatternPickerOption(comp, indexPatternMock.title); + }); + expect(props.onChangeIndexPattern).toHaveBeenCalledTimes(0); + }); + test('switching index pattern to a different index pattern triggers onChangeIndexPattern', async () => { + const props = getProps(); + const comp = shallowWithIntl(); + await act(async () => { + selectIndexPatternPickerOption(comp, indexPatternWithTimefieldMock.title); + }); + expect(props.onChangeIndexPattern).toHaveBeenCalledTimes(1); + expect(props.onChangeIndexPattern).toHaveBeenCalledWith(indexPatternWithTimefieldMock.id); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/change_indexpattern.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/change_indexpattern.tsx index d5076e4daa990..5f2f35e2419dd 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/change_indexpattern.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/change_indexpattern.tsx @@ -26,17 +26,17 @@ export type ChangeIndexPatternTriggerProps = EuiButtonProps & { // TODO: refactor to shared component with ../../../../../../../../x-pack/legacy/plugins/lens/public/indexpattern_plugin/change_indexpattern export function ChangeIndexPattern({ - indexPatternRefs, indexPatternId, + indexPatternRefs, onChangeIndexPattern, - trigger, selectableProps, + trigger, }: { - trigger: ChangeIndexPatternTriggerProps; + indexPatternId?: string; indexPatternRefs: IndexPatternRef[]; onChangeIndexPattern: (newId: string) => void; - indexPatternId?: string; selectableProps?: EuiSelectableProps<{ value: string }>; + trigger: ChangeIndexPatternTriggerProps; }) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); @@ -86,7 +86,9 @@ export function ChangeIndexPattern({ const choice = (choices.find(({ checked }) => checked) as unknown) as { value: string; }; - onChangeIndexPattern(choice.value); + if (choice.value !== indexPatternId) { + onChangeIndexPattern(choice.value); + } setPopoverIsOpen(false); }} searchProps={{ diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx index e60dabd1d8d8c..301866c762fbd 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx @@ -8,7 +8,7 @@ import './discover_field.scss'; -import React, { useState } from 'react'; +import React, { useState, useCallback, memo, useMemo } from 'react'; import { EuiPopover, EuiPopoverTitle, @@ -18,6 +18,7 @@ import { EuiIcon, EuiFlexGroup, EuiFlexItem, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UiCounterMetricType } from '@kbn/analytics'; @@ -27,7 +28,174 @@ import { FieldIcon, FieldButton } from '../../../../../../../kibana_react/public import { FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../../../data/public'; import { getFieldTypeName } from './lib/get_field_type_name'; -import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; +import { DiscoverFieldVisualize } from './discover_field_visualize'; + +function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; +} + +const FieldInfoIcon: React.FC = memo(() => ( + + + +)); + +const DiscoverFieldTypeIcon: React.FC<{ field: IndexPatternField }> = memo(({ field }) => ( + +)); + +const FieldName: React.FC<{ field: IndexPatternField }> = memo(({ field }) => { + const title = + field.displayName !== field.name + ? i18n.translate('discover.field.title', { + defaultMessage: '{fieldName} ({fieldDisplayName})', + values: { + fieldName: field.name, + fieldDisplayName: field.displayName, + }, + }) + : field.displayName; + + return ( + + {wrapOnDot(field.displayName)} + + ); +}); + +interface ActionButtonProps { + field: IndexPatternField; + isSelected?: boolean; + alwaysShow: boolean; + toggleDisplay: (field: IndexPatternField) => void; +} + +const ActionButton: React.FC = memo( + ({ field, isSelected, alwaysShow, toggleDisplay }) => { + const actionBtnClassName = classNames('dscSidebarItem__action', { + ['dscSidebarItem__mobile']: alwaysShow, + }); + if (field.name === '_source') { + return null; + } + if (!isSelected) { + return ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { + defaultMessage: 'Add {field} to table', + values: { field: field.name }, + })} + /> + + ); + } else { + return ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={i18n.translate( + 'discover.fieldChooser.discoverField.removeButtonAriaLabel', + { + defaultMessage: 'Remove {field} from table', + values: { field: field.name }, + } + )} + /> + + ); + } + } +); + +interface MultiFieldsProps { + multiFields: NonNullable; + toggleDisplay: (field: IndexPatternField) => void; + alwaysShowActionButton: boolean; +} + +const MultiFields: React.FC = memo( + ({ multiFields, toggleDisplay, alwaysShowActionButton }) => ( + + +
    + {i18n.translate('discover.fieldChooser.discoverField.multiFields', { + defaultMessage: 'Multi fields', + })} +
    +
    + + {multiFields.map((entry) => ( + } + fieldAction={ + + } + fieldName={} + key={entry.field.name} + /> + ))} +
    + ) +); export interface DiscoverFieldProps { /** @@ -85,7 +253,7 @@ export interface DiscoverFieldProps { onDeleteField?: (fieldName: string) => void; } -export function DiscoverField({ +function DiscoverFieldComponent({ alwaysShowActionButton = false, field, indexPattern, @@ -99,133 +267,24 @@ export function DiscoverField({ onEditField, onDeleteField, }: DiscoverFieldProps) { - const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { - defaultMessage: 'Add {field} to table', - values: { field: field.name }, - }); - const removeLabelAria = i18n.translate( - 'discover.fieldChooser.discoverField.removeButtonAriaLabel', - { - defaultMessage: 'Remove {field} from table', - values: { field: field.name }, - } - ); - const [infoIsOpen, setOpen] = useState(false); - const toggleDisplay = (f: IndexPatternField, isSelected: boolean) => { - if (isSelected) { - onRemoveField(f.name); - } else { - onAddField(f.name); - } - }; + const toggleDisplay = useCallback( + (f: IndexPatternField) => { + if (selected) { + onRemoveField(f.name); + } else { + onAddField(f.name); + } + }, + [onAddField, onRemoveField, selected] + ); - function togglePopover() { + const togglePopover = useCallback(() => { setOpen(!infoIsOpen); - } - - function wrapOnDot(str?: string) { - // u200B is a non-width white-space character, which allows - // the browser to efficiently word-wrap right after the dot - // without us having to draw a lot of extra DOM elements, etc - return str ? str.replace(/\./g, '.\u200B') : ''; - } - - const getDscFieldIcon = (indexPatternField: IndexPatternField) => { - return ( - - ); - }; - - const dscFieldIcon = getDscFieldIcon(field); - - const getTitle = (indexPatternField: IndexPatternField) => { - return indexPatternField.displayName !== indexPatternField.name - ? i18n.translate('discover.field.title', { - defaultMessage: '{fieldName} ({fieldDisplayName})', - values: { - fieldName: indexPatternField.name, - fieldDisplayName: indexPatternField.displayName, - }, - }) - : indexPatternField.displayName; - }; - - const getFieldName = (indexPatternField: IndexPatternField) => { - return ( - - {wrapOnDot(indexPatternField.displayName)} - - ); - }; - const fieldName = getFieldName(field); - - const actionBtnClassName = classNames('dscSidebarItem__action', { - ['dscSidebarItem__mobile']: alwaysShowActionButton, - }); - const getActionButton = (f: IndexPatternField, isSelected?: boolean) => { - if (f.name !== '_source' && !isSelected) { - return ( - - ) => { - if (ev.type === 'click') { - ev.currentTarget.focus(); - } - ev.preventDefault(); - ev.stopPropagation(); - toggleDisplay(f, false); - }} - data-test-subj={`fieldToggle-${f.name}`} - aria-label={addLabelAria} - /> - - ); - } else if (f.name !== '_source' && isSelected) { - return ( - - ) => { - if (ev.type === 'click') { - ev.currentTarget.focus(); - } - ev.preventDefault(); - ev.stopPropagation(); - toggleDisplay(f, isSelected); - }} - data-test-subj={`fieldToggle-${f.name}`} - aria-label={removeLabelAria} - /> - - ); - } - }; + }, [infoIsOpen]); - const actionButton = getActionButton(field, selected); + const rawMultiFields = useMemo(() => multiFields?.map((f) => f.field), [multiFields]); if (field.type === '_source') { return ( @@ -233,71 +292,20 @@ export function DiscoverField({ size="s" className="dscSidebarItem" dataTestSubj={`field-${field.name}-showDetails`} - fieldIcon={dscFieldIcon} - fieldAction={actionButton} - fieldName={fieldName} + fieldIcon={} + fieldAction={ + + } + fieldName={} /> ); } - const getFieldInfoIcon = () => { - if (field.type !== 'conflict') { - return null; - } - return ( - - - - ); - }; - - const fieldInfoIcon = getFieldInfoIcon(); - - const shouldRenderMultiFields = !!multiFields; - const renderMultiFields = () => { - if (!multiFields) { - return null; - } - return ( - - -
    - {i18n.translate('discover.fieldChooser.discoverField.multiFields', { - defaultMessage: 'Multi fields', - })} -
    -
    - {multiFields.map((entry) => ( - {}} - dataTestSubj={`field-${entry.field.name}-showDetails`} - fieldIcon={getDscFieldIcon(entry.field)} - fieldAction={getActionButton(entry.field, entry.isSelected)} - fieldName={getFieldName(entry.field)} - key={entry.field.name} - /> - ))} -
    - ); - }; - const isRuntimeField = Boolean(indexPattern.getFieldByName(field.name)?.runtimeField); const isUnknownField = field.type === 'unknown' || field.type === 'unknown_selected'; const canEditField = onEditField && (!isUnknownField || isRuntimeField); @@ -334,9 +342,7 @@ export function DiscoverField({ > { - if (onDeleteField) { - onDeleteField(field.name); - } + onDeleteField?.(field.name); }} iconType="trash" data-test-subj={`discoverFieldListPanelDelete-${field.name}`} @@ -352,6 +358,8 @@ export function DiscoverField({ ); + const details = getDetails(field); + return ( { - togglePopover(); - }} + onClick={togglePopover} dataTestSubj={`field-${field.name}-showDetails`} - fieldIcon={dscFieldIcon} - fieldAction={actionButton} - fieldName={fieldName} - fieldInfoIcon={fieldInfoIcon} + fieldIcon={} + fieldAction={ + + } + fieldName={} + fieldInfoIcon={field.type === 'conflict' && } /> } isOpen={infoIsOpen} @@ -384,26 +397,34 @@ export function DiscoverField({

    {infoIsOpen && ( - - )} - {shouldRenderMultiFields ? ( <> - {renderMultiFields()} - + {multiFields && ( + <> + + + + )} + - ) : null} + )} ); } + +export const DiscoverField = memo(DiscoverFieldComponent); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.scss b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.scss deleted file mode 100644 index ca48d67f75dec..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.scss +++ /dev/null @@ -1,10 +0,0 @@ -.dscFieldDetails { - color: $euiTextColor; - margin-bottom: $euiSizeS; -} - -.dscFieldDetails__visualizeBtn { - @include euiFontSizeXS; - height: $euiSizeL !important; - min-width: $euiSize * 4; -} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.test.tsx index a798abb60b833..8c9ad5bc9708a 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.test.tsx @@ -25,10 +25,11 @@ const indexPattern = getStubIndexPattern( ); describe('discover sidebar field details', function () { + const onAddFilter = jest.fn(); const defaultProps = { indexPattern, details: { buckets: [], error: '', exists: 1, total: 2, columns: [] }, - onAddFilter: jest.fn(), + onAddFilter, }; function mountComponent(field: IndexPatternField) { @@ -36,7 +37,7 @@ describe('discover sidebar field details', function () { return mountWithIntl(); } - it('should enable the visualize link for a number field', function () { + it('click on addFilter calls the function', function () { const visualizableField = new IndexPatternField({ name: 'bytes', type: 'number', @@ -47,37 +48,9 @@ describe('discover sidebar field details', function () { aggregatable: true, readFromDocValues: true, }); - const comp = mountComponent(visualizableField); - expect(findTestSubject(comp, 'fieldVisualize-bytes')).toBeTruthy(); - }); - - it('should disable the visualize link for an _id field', function () { - const conflictField = new IndexPatternField({ - name: '_id', - type: 'string', - esTypes: ['_id'], - count: 0, - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }); - const comp = mountComponent(conflictField); - expect(findTestSubject(comp, 'fieldVisualize-_id')).toEqual({}); - }); - - it('should disable the visualize link for an unknown field', function () { - const unknownField = new IndexPatternField({ - name: 'test', - type: 'unknown', - esTypes: ['double'], - count: 0, - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }); - const comp = mountComponent(unknownField); - expect(findTestSubject(comp, 'fieldVisualize-test')).toEqual({}); + const component = mountComponent(visualizableField); + const onAddButton = findTestSubject(component, 'onAddFilterButton'); + onAddButton.simulate('click'); + expect(onAddFilter).toHaveBeenCalledWith('_exists_', visualizableField.name, '+'); }); }); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx index d7008ba3e310f..e29799b720e21 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx @@ -6,29 +6,18 @@ * Side Public License, v 1. */ -import React, { useState, useEffect } from 'react'; -import { EuiIconTip, EuiText, EuiButton, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { EuiText, EuiSpacer, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; import { DiscoverFieldBucket } from './discover_field_bucket'; -import { getWarnings } from './lib/get_warnings'; -import { - triggerVisualizeActions, - isFieldVisualizable, - getVisualizeHref, -} from './lib/visualize_trigger_utils'; import { Bucket, FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../../../data/public'; -import './discover_field_details.scss'; -import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; interface DiscoverFieldDetailsProps { field: IndexPatternField; indexPattern: IndexPattern; details: FieldDetails; onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; - trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; - showFooter?: boolean; } export function DiscoverFieldDetails({ @@ -36,47 +25,12 @@ export function DiscoverFieldDetails({ indexPattern, details, onAddFilter, - trackUiMetric, - showFooter = true, }: DiscoverFieldDetailsProps) { - const warnings = getWarnings(field); - const [showVisualizeLink, setShowVisualizeLink] = useState(false); - const [visualizeLink, setVisualizeLink] = useState(''); - - useEffect(() => { - isFieldVisualizable(field, indexPattern.id, details.columns).then( - (flag) => { - setShowVisualizeLink(flag); - // get href only if Visualize button is enabled - getVisualizeHref(field, indexPattern.id, details.columns).then( - (uri) => { - if (uri) setVisualizeLink(uri); - }, - () => { - setVisualizeLink(''); - } - ); - }, - () => { - setShowVisualizeLink(false); - } - ); - }, [field, indexPattern.id, details.columns]); - - const handleVisualizeLinkClick = (event: React.MouseEvent) => { - // regular link click. let the uiActions code handle the navigation and show popup if needed - event.preventDefault(); - if (trackUiMetric) { - trackUiMetric(METRIC_TYPE.CLICK, 'visualize_link_click'); - } - triggerVisualizeActions(field, indexPattern.id, details.columns); - }; - return ( <> -
    - {details.error && {details.error}} - {!details.error && ( + {details.error && {details.error}} + {!details.error && ( + <>
    {details.buckets.map((bucket: Bucket, idx: number) => ( ))}
    - )} - - {showVisualizeLink && ( - <> - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - handleVisualizeLinkClick(e)} - href={visualizeLink} - size="s" - className="dscFieldDetails__visualizeBtn" - data-test-subj={`fieldVisualize-${field.name}`} - > + + + {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( + onAddFilter('_exists_', field.name, '+')} + data-test-subj="onAddFilterButton" + > + + + ) : ( - - {warnings.length > 0 && ( - )} - - )} -
    - {!details.error && showFooter && ( - + + )} ); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.test.tsx deleted file mode 100644 index aa93b2a663736..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { findTestSubject } from '@elastic/eui/lib/test'; -import { mountWithIntl } from '@kbn/test/jest'; -// @ts-expect-error -import stubbedLogstashFields from '../../../../../__fixtures__/logstash_fields'; -import { coreMock } from '../../../../../../../../core/public/mocks'; -import { IndexPatternField } from '../../../../../../../data/public'; -import { getStubIndexPattern } from '../../../../../../../data/public/test_utils'; -import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; - -const indexPattern = getStubIndexPattern( - 'logstash-*', - (cfg: unknown) => cfg, - 'time', - stubbedLogstashFields(), - coreMock.createSetup() -); - -describe('discover sidebar field details footer', function () { - const onAddFilter = jest.fn(); - const defaultProps = { - indexPattern, - details: { buckets: [], error: '', exists: 1, total: 2, columns: [] }, - onAddFilter, - }; - - function mountComponent(field: IndexPatternField) { - const compProps = { ...defaultProps, field }; - return mountWithIntl(); - } - - it('renders properly', function () { - const visualizableField = new IndexPatternField({ - name: 'bytes', - type: 'number', - esTypes: ['long'], - count: 10, - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }); - const component = mountComponent(visualizableField); - expect(component).toMatchSnapshot(); - }); - - it('click on addFilter calls the function', function () { - const visualizableField = new IndexPatternField({ - name: 'bytes', - type: 'number', - esTypes: ['long'], - count: 10, - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }); - const component = mountComponent(visualizableField); - const onAddButton = findTestSubject(component, 'onAddFilterButton'); - onAddButton.simulate('click'); - expect(onAddFilter).toHaveBeenCalledWith('_exists_', visualizableField.name, '+'); - }); -}); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.tsx deleted file mode 100644 index 148dfc67c3e41..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.tsx +++ /dev/null @@ -1,59 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EuiLink, EuiPopoverFooter, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { IndexPatternField } from '../../../../../../../data/common/index_patterns/fields'; -import { IndexPattern } from '../../../../../../../data/common/index_patterns/index_patterns'; -import { FieldDetails } from './types'; - -interface DiscoverFieldDetailsFooterProps { - field: IndexPatternField; - indexPattern: IndexPattern; - details: FieldDetails; - onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; -} - -export function DiscoverFieldDetailsFooter({ - field, - indexPattern, - details, - onAddFilter, -}: DiscoverFieldDetailsFooterProps) { - return ( - - - {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( - onAddFilter('_exists_', field.name, '+')} - data-test-subj="onAddFilterButton" - > - - - ) : ( - - )} - - - ); -} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_search.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_search.tsx index e11c1716efe6b..4abfa6ecea55a 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_search.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_search.tsx @@ -204,15 +204,21 @@ export function DiscoverFieldSearch({ onChange, value, types, useNewFieldsApi }: return [ { id: `${id}-any`, - label: 'any', + label: i18n.translate('discover.fieldChooser.filter.toggleButton.any', { + defaultMessage: 'any', + }), }, { id: `${id}-true`, - label: 'yes', + label: i18n.translate('discover.fieldChooser.filter.toggleButton.yes', { + defaultMessage: 'yes', + }), }, { id: `${id}-false`, - label: 'no', + label: i18n.translate('discover.fieldChooser.filter.toggleButton.no', { + defaultMessage: 'no', + }), }, ]; }; diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx new file mode 100644 index 0000000000000..baf740531e6bf --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, useState } from 'react'; +import { EuiButton, EuiPopoverFooter } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import type { IndexPattern, IndexPatternField } from 'src/plugins/data/common'; + +import { triggerVisualizeActions, VisualizeInformation } from './lib/visualize_trigger_utils'; +import type { FieldDetails } from './types'; +import { getVisualizeInformation } from './lib/visualize_trigger_utils'; + +interface Props { + field: IndexPatternField; + indexPattern: IndexPattern; + details: FieldDetails; + multiFields?: IndexPatternField[]; + trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; +} + +export const DiscoverFieldVisualize: React.FC = React.memo( + ({ field, indexPattern, details, trackUiMetric, multiFields }) => { + const [visualizeInfo, setVisualizeInfo] = useState(); + + useEffect(() => { + getVisualizeInformation(field, indexPattern.id, details.columns, multiFields).then( + setVisualizeInfo + ); + }, [details.columns, field, indexPattern, multiFields]); + + if (!visualizeInfo) { + return null; + } + + const handleVisualizeLinkClick = (event: React.MouseEvent) => { + // regular link click. let the uiActions code handle the navigation and show popup if needed + event.preventDefault(); + trackUiMetric?.(METRIC_TYPE.CLICK, 'visualize_link_click'); + triggerVisualizeActions(visualizeInfo.field, indexPattern.id, details.columns); + }; + + return ( + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + ); + } +); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx index 0bebec61657b4..7f8866a2ee369 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx @@ -21,6 +21,7 @@ import { EuiPageSideBar, useResizeObserver, } from '@elastic/eui'; +import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; import { isEqual, sortBy } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -81,7 +82,6 @@ export function DiscoverSidebar({ trackUiMetric, useNewFieldsApi = false, useFlyout = false, - unmappedFieldsConfig, onEditRuntimeField, onChangeIndexPattern, setFieldEditorRef, @@ -128,25 +128,8 @@ export function DiscoverSidebar({ popular: popularFields, unpopular: unpopularFields, } = useMemo( - () => - groupFields( - fields, - columns, - popularLimit, - fieldCounts, - fieldFilter, - useNewFieldsApi, - !!unmappedFieldsConfig?.showUnmappedFields - ), - [ - fields, - columns, - popularLimit, - fieldCounts, - fieldFilter, - useNewFieldsApi, - unmappedFieldsConfig?.showUnmappedFields, - ] + () => groupFields(fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi), + [fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi] ); const paginate = useCallback(() => { @@ -205,7 +188,7 @@ export function DiscoverSidebar({ return result; }, [fields]); - const multiFields = useMemo(() => { + const calculateMultiFields = () => { if (!useNewFieldsApi || !fields) { return undefined; } @@ -224,7 +207,13 @@ export function DiscoverSidebar({ map.set(parent, value); }); return map; - }, [fields, useNewFieldsApi, selectedFields]); + }; + + const [multiFields, setMultiFields] = useState(() => calculateMultiFields()); + + useShallowCompareEffect(() => { + setMultiFields(calculateMultiFields()); + }, [fields, selectedFields, useNewFieldsApi]); const deleteField = useMemo( () => diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx index 2ad75806173eb..6973221fd3624 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -25,7 +25,6 @@ import { } from './discover_sidebar_responsive'; import { DiscoverServices } from '../../../../../build_services'; import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; -import { DiscoverSidebar } from './discover_sidebar'; const mockServices = ({ history: () => ({ @@ -132,14 +131,4 @@ describe('discover responsive sidebar', function () { findTestSubject(comp, 'plus-extension-gif').simulate('click'); expect(props.onAddFilter).toHaveBeenCalled(); }); - it('renders sidebar with unmapped fields config', function () { - const unmappedFieldsConfig = { - showUnmappedFields: false, - }; - const componentProps = { ...props, unmappedFieldsConfig }; - const component = mountWithIntl(); - const discoverSidebar = component.find(DiscoverSidebar); - expect(discoverSidebar).toHaveLength(1); - expect(discoverSidebar.props().unmappedFieldsConfig).toEqual(unmappedFieldsConfig); - }); }); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx index cc33601f77728..003bb22599e48 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx @@ -105,15 +105,6 @@ export interface DiscoverSidebarResponsiveProps { * Read from the Fields API */ useNewFieldsApi?: boolean; - /** - * an object containing properties for proper handling of unmapped fields - */ - unmappedFieldsConfig?: { - /** - * determines whether to display unmapped fields - */ - showUnmappedFields: boolean; - }; /** * callback to execute on edit runtime field */ diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/get_warnings.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/get_warnings.ts deleted file mode 100644 index 60ce5351e2cd3..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/get_warnings.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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { IndexPatternField } from '../../../../../../../../data/public'; - -export function getWarnings(field: IndexPatternField) { - let warnings = []; - - if (field.scripted) { - warnings.push( - i18n.translate( - 'discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription', - { - defaultMessage: 'Scripted fields can take a long time to execute.', - } - ) - ); - } - - if (warnings.length > 1) { - warnings = warnings.map(function (warning, i) { - return (i > 0 ? '\n' : '') + (i + 1) + ' - ' + warning; - }); - } - - return warnings; -} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts index 5869720635621..cd9f6b3cac4a5 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts @@ -244,8 +244,7 @@ describe('group_fields', function () { 5, fieldCounts, fieldFilterState, - true, - false + true ); expect(actual.unpopular).toEqual([]); }); @@ -270,8 +269,7 @@ describe('group_fields', function () { 5, fieldCounts, fieldFilterState, - false, - undefined + false ); expect(actual.unpopular.map((field) => field.name)).toEqual(['unknown_field']); }); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx index dc6cbcedc8086..2007d32fe84be 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx @@ -24,9 +24,9 @@ export function groupFields( popularLimit: number, fieldCounts: Record, fieldFilterState: FieldFilterState, - useNewFieldsApi: boolean, - showUnmappedFields = true + useNewFieldsApi: boolean ): GroupedFields { + const showUnmappedFields = useNewFieldsApi; const result: GroupedFields = { selected: [], popular: [], diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.test.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.test.ts new file mode 100644 index 0000000000000..0a61bf1ea6029 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndexPatternField } from 'src/plugins/data/common'; +import type { Action } from 'src/plugins/ui_actions/public'; +import { getVisualizeInformation } from './visualize_trigger_utils'; + +const field = { + name: 'fieldName', + type: 'string', + esTypes: ['text'], + count: 1, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + visualizable: true, +} as IndexPatternField; + +const mockGetActions = jest.fn>>, [string, { fieldName: string }]>( + () => Promise.resolve([]) +); + +jest.mock('../../../../../../kibana_services', () => ({ + getUiActions: () => ({ + getTriggerCompatibleActions: mockGetActions, + }), +})); + +const action: Action = { + id: 'action', + type: 'VISUALIZE_FIELD', + getIconType: () => undefined, + getDisplayName: () => 'Action', + isCompatible: () => Promise.resolve(true), + execute: () => Promise.resolve(), +}; + +describe('visualize_trigger_utils', () => { + afterEach(() => { + mockGetActions.mockReset(); + }); + + describe('getVisualizeInformation', () => { + it('should return for a visualizeable field with an action', async () => { + mockGetActions.mockResolvedValue([action]); + const information = await getVisualizeInformation(field, '1', [], undefined); + expect(information).not.toBeUndefined(); + expect(information?.field).toHaveProperty('name', 'fieldName'); + expect(information?.href).toBeUndefined(); + }); + + it('should return field and href from the action', async () => { + mockGetActions.mockResolvedValue([{ ...action, getHref: () => Promise.resolve('hreflink') }]); + const information = await getVisualizeInformation(field, '1', [], undefined); + expect(information).not.toBeUndefined(); + expect(information?.field).toHaveProperty('name', 'fieldName'); + expect(information).toHaveProperty('href', 'hreflink'); + }); + + it('should return undefined if no field has a compatible action', async () => { + mockGetActions.mockResolvedValue([]); + const information = await getVisualizeInformation( + { ...field, name: 'rootField' } as IndexPatternField, + '1', + [], + [ + { ...field, name: 'multi1' }, + { ...field, name: 'multi2' }, + ] as IndexPatternField[] + ); + expect(information).toBeUndefined(); + }); + + it('should return information for the root field, when multi fields and root are having actions', async () => { + mockGetActions.mockResolvedValue([action]); + const information = await getVisualizeInformation( + { ...field, name: 'rootField' } as IndexPatternField, + '1', + [], + [ + { ...field, name: 'multi1' }, + { ...field, name: 'multi2' }, + ] as IndexPatternField[] + ); + expect(information).not.toBeUndefined(); + expect(information?.field).toHaveProperty('name', 'rootField'); + }); + + it('should return information for first multi field that has a compatible action', async () => { + mockGetActions.mockImplementation(async (_, { fieldName }) => { + if (fieldName === 'multi2' || fieldName === 'multi3') { + return [action]; + } + return []; + }); + const information = await getVisualizeInformation( + { ...field, name: 'rootField' } as IndexPatternField, + '1', + [], + [ + { ...field, name: 'multi1' }, + { ...field, name: 'multi2' }, + { ...field, name: 'multi3' }, + ] as IndexPatternField[] + ); + expect(information).not.toBeUndefined(); + expect(information?.field).toHaveProperty('name', 'multi2'); + }); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.ts index 2fabaa0ddd100..f00b430e5acef 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.ts +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.ts @@ -41,30 +41,6 @@ async function getCompatibleActions( return compatibleActions; } -export async function getVisualizeHref( - field: IndexPatternField, - indexPatternId: string | undefined, - contextualFields: string[] -) { - if (!indexPatternId) return undefined; - const triggerOptions = { - indexPatternId, - fieldName: field.name, - contextualFields, - trigger: getTrigger(field.type), - }; - const compatibleActions = await getCompatibleActions( - field.name, - indexPatternId, - contextualFields, - getTriggerConstant(field.type) - ); - // enable the link only if only one action is registered - return compatibleActions.length === 1 - ? compatibleActions[0].getHref?.(triggerOptions) - : undefined; -} - export function triggerVisualizeActions( field: IndexPatternField, indexPatternId: string | undefined, @@ -80,21 +56,55 @@ export function triggerVisualizeActions( getUiActions().getTrigger(trigger).exec(triggerOptions); } -export async function isFieldVisualizable( +export interface VisualizeInformation { + field: IndexPatternField; + href?: string; +} + +/** + * Returns the field name and potentially href of the field or the first multi-field + * that has a compatible visualize uiAction. + */ +export async function getVisualizeInformation( field: IndexPatternField, indexPatternId: string | undefined, - contextualFields: string[] -) { + contextualFields: string[], + multiFields: IndexPatternField[] = [] +): Promise { if (field.name === '_id' || !indexPatternId) { - // for first condition you'd get a 'Fielddata access on the _id field is disallowed' error on ES side. - return false; + // _id fields are not visualizeable in ES + return undefined; } - const trigger = getTriggerConstant(field.type); - const compatibleActions = await getCompatibleActions( - field.name, - indexPatternId, - contextualFields, - trigger - ); - return compatibleActions.length > 0 && field.visualizable; + + for (const f of [field, ...multiFields]) { + if (!f.visualizable) { + continue; + } + // Retrieve compatible actions for the specific field + const actions = await getCompatibleActions( + f.name, + indexPatternId, + contextualFields, + getTriggerConstant(f.type) + ); + + // if the field has compatible actions use this field for visualizing + if (actions.length > 0) { + const triggerOptions = { + indexPatternId, + fieldName: f.name, + contextualFields, + trigger: getTrigger(f.type), + }; + + return { + field: f, + // We use the href of the first action always. Multiple actions will only work + // via the modal shown by triggerVisualizeActions that should be called via onClick. + href: await actions[0].getHref?.(triggerOptions), + }; + } + } + + return undefined; } diff --git a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx index 5cc7147b49ff9..07939fff6e7f4 100644 --- a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx +++ b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx @@ -5,15 +5,12 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useMemo, useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { History } from 'history'; import { DiscoverLayout } from './components/layout'; -import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; -import { useSavedSearch as useSavedSearchData } from './services/use_saved_search'; import { setBreadcrumbsTitle } from '../../helpers/breadcrumbs'; import { addHelpMenuToAppChrome } from '../../components/help_menu/help_menu_util'; import { useDiscoverState } from './services/use_discover_state'; -import { useSearchSession } from './services/use_search_session'; import { useUrl } from './services/use_url'; import { IndexPattern, IndexPatternAttributes, SavedObject } from '../../../../../data/common'; import { DiscoverServices } from '../../../build_services'; @@ -55,18 +52,20 @@ export function DiscoverMainApp(props: DiscoverMainProps) { const { services, history, navigateTo, indexPatternList } = props.opts; const { chrome, docLinks, uiSettings: config, data } = services; - const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); - /** * State related logic */ const { - stateContainer, - state, + data$, indexPattern, - searchSource, - savedSearch, + onChangeIndexPattern, + onUpdateQuery, + refetch$, resetSavedSearch, + savedSearch, + searchSource, + state, + stateContainer, } = useDiscoverState({ services, history, @@ -79,25 +78,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) { */ useUrl({ history, resetSavedSearch }); - /** - * Search session logic - */ - const searchSessionManager = useSearchSession({ services, history, stateContainer, savedSearch }); - - /** - * Data fetching logic - */ - const { data$, refetch$ } = useSavedSearchData({ - indexPattern, - savedSearch, - searchSessionManager, - searchSource, - services, - state, - stateContainer, - useNewFieldsApi, - }); - /** * SavedSearch depended initializing */ @@ -115,11 +95,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) { */ useEffect(() => { addHelpMenuToAppChrome(chrome, docLinks); - stateContainer.replaceUrlAppState({}).then(() => { - stateContainer.startSync(); - }); - - return () => stateContainer.stopSync(); }, [stateContainer, chrome, docLinks]); const resetQuery = useCallback(() => { @@ -130,12 +105,13 @@ export function DiscoverMainApp(props: DiscoverMainProps) { ; + /** + * Function starting state sync when Discover main is loaded + */ + initializeAndSync: ( + indexPattern: IndexPattern, + filterManager: FilterManager, + data: DataPublicPluginStart + ) => () => void; /** * Start sync between state and URL */ @@ -204,16 +216,18 @@ export function getState({ stateStorage, }); + const replaceUrlAppState = async (newPartial: AppState = {}) => { + const state = { ...appStateContainer.getState(), ...newPartial }; + await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true }); + }; + return { kbnUrlStateStorage: stateStorage, appStateContainer: appStateContainerModified, startSync: start, stopSync: stop, setAppState: (newPartial: AppState) => setState(appStateContainerModified, newPartial), - replaceUrlAppState: async (newPartial: AppState = {}) => { - const state = { ...appStateContainer.getState(), ...newPartial }; - await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true }); - }, + replaceUrlAppState, resetInitialAppState: () => { initialAppState = appStateContainer.getState(); }, @@ -224,6 +238,50 @@ export function getState({ getPreviousAppState: () => previousAppState, flushToUrl: () => stateStorage.kbnUrlControls.flush(), isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()), + initializeAndSync: ( + indexPattern: IndexPattern, + filterManager: FilterManager, + data: DataPublicPluginStart + ) => { + if (appStateContainer.getState().index !== indexPattern.id) { + // used index pattern is different than the given by url/state which is invalid + setState(appStateContainerModified, { index: indexPattern.id }); + } + // sync initial app filters from state to filterManager + const filters = appStateContainer.getState().filters; + if (filters) { + filterManager.setAppFilters(cloneDeep(filters)); + } + const query = appStateContainer.getState().query; + if (query) { + data.query.queryString.setQuery(query); + } + + const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( + data.query, + appStateContainer, + { + filters: esFilters.FilterStateStore.APP_STATE, + query: true, + } + ); + + // syncs `_g` portion of url with query services + const { stop: stopSyncingGlobalStateWithUrl } = syncQueryStateWithUrl( + data.query, + stateStorage + ); + + replaceUrlAppState({}).then(() => { + start(); + }); + + return () => { + stopSyncingQueryAppStateWithStateContainer(); + stopSyncingGlobalStateWithUrl(); + stop(); + }; + }, }; } diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts index 051a2d2dcd9cc..4c3d819f063a0 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts @@ -62,10 +62,6 @@ describe('test useDiscoverState', () => { }); }); - await act(async () => { - result.current.stateContainer.startSync(); - }); - const initialColumns = result.current.state.columns; await act(async () => { result.current.setState({ columns: ['123'] }); diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts index a3546d54cd493..3c736f09a8296 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts @@ -6,19 +6,25 @@ * Side Public License, v 1. */ import { useMemo, useEffect, useState, useCallback } from 'react'; -import { cloneDeep } from 'lodash'; +import { isEqual } from 'lodash'; import { History } from 'history'; import { getState } from './discover_state'; import { getStateDefaults } from '../utils/get_state_defaults'; -import { - esFilters, - connectToQueryState, - syncQueryStateWithUrl, - IndexPattern, -} from '../../../../../../data/public'; +import { IndexPattern } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; import { SavedSearch } from '../../../../saved_searches'; import { loadIndexPattern } from '../utils/resolve_index_pattern'; +import { useSavedSearch as useSavedSearchData } from './use_saved_search'; +import { + MODIFY_COLUMNS_ON_SWITCH, + SEARCH_FIELDS_FROM_SOURCE, + SEARCH_ON_PAGE_LOAD_SETTING, + SORT_DEFAULT_ORDER_SETTING, +} from '../../../../../common'; +import { useSearchSession } from './use_search_session'; +import { FetchStatus } from '../../../types'; +import { getSwitchIndexPatternAppState } from '../utils/get_switch_index_pattern_app_state'; +import { SortPairArr } from '../../../angular/doc_table/lib/get_sort'; export function useDiscoverState({ services, @@ -31,9 +37,11 @@ export function useDiscoverState({ history: History; initialIndexPattern: IndexPattern; }) { - const { uiSettings: config, data, filterManager } = services; + const { uiSettings: config, data, filterManager, indexPatterns } = services; const [indexPattern, setIndexPattern] = useState(initialIndexPattern); const [savedSearch, setSavedSearch] = useState(initialSavedSearch); + const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); + const timefilter = data.query.timefilter.timefilter; const searchSource = useMemo(() => { savedSearch.searchSource.setField('index', indexPattern); @@ -57,73 +65,80 @@ export function useDiscoverState({ [config, data, history, savedSearch, services.core.notifications.toasts] ); - const { appStateContainer, getPreviousAppState } = stateContainer; + const { appStateContainer } = stateContainer; const [state, setState] = useState(appStateContainer.getState()); - useEffect(() => { - if (stateContainer.appStateContainer.getState().index !== indexPattern.id) { - // used index pattern is different than the given by url/state which is invalid - stateContainer.setAppState({ index: indexPattern.id }); - } - // sync initial app filters from state to filterManager - const filters = appStateContainer.getState().filters; - if (filters) { - filterManager.setAppFilters(cloneDeep(filters)); - } - const query = appStateContainer.getState().query; - if (query) { - data.query.queryString.setQuery(query); - } + /** + * Search session logic + */ + const searchSessionManager = useSearchSession({ services, history, stateContainer, savedSearch }); - const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( - data.query, - appStateContainer, - { - filters: esFilters.FilterStateStore.APP_STATE, - query: true, - } - ); + const initialFetchStatus: FetchStatus = useMemo(() => { + // A saved search is created on every page load, so we check the ID to see if we're loading a + // previously saved search or if it is just transient + const shouldSearchOnPageLoad = + config.get(SEARCH_ON_PAGE_LOAD_SETTING) || + savedSearch.id !== undefined || + timefilter.getRefreshInterval().pause === false || + searchSessionManager.hasSearchSessionIdInURL(); + return shouldSearchOnPageLoad ? FetchStatus.LOADING : FetchStatus.UNINITIALIZED; + }, [config, savedSearch.id, searchSessionManager, timefilter]); - // syncs `_g` portion of url with query services - const { stop: stopSyncingGlobalStateWithUrl } = syncQueryStateWithUrl( - data.query, - stateContainer.kbnUrlStateStorage - ); + /** + * Data fetching logic + */ + const { data$, refetch$, reset } = useSavedSearchData({ + indexPattern, + initialFetchStatus, + searchSessionManager, + searchSource, + services, + stateContainer, + useNewFieldsApi, + }); + + useEffect(() => { + const stopSync = stateContainer.initializeAndSync(indexPattern, filterManager, data); return () => { - stopSyncingQueryAppStateWithStateContainer(); - stopSyncingGlobalStateWithUrl(); + stopSync(); }; - }, [ - appStateContainer, - config, - data.query, - data.search.session, - getPreviousAppState, - indexPattern.id, - filterManager, - services.indexPatterns, - stateContainer, - ]); + }, [stateContainer, filterManager, data, indexPattern]); + /** + * Track state changes that should trigger a fetch + */ useEffect(() => { - const unsubscribe = stateContainer.appStateContainer.subscribe(async (nextState) => { + const unsubscribe = appStateContainer.subscribe(async (nextState) => { + const { hideChart, interval, sort, index } = state; + // chart was hidden, now it should be displayed, so data is needed + const chartDisplayChanged = nextState.hideChart !== hideChart && hideChart; + const chartIntervalChanged = nextState.interval !== interval; + const docTableSortChanged = !isEqual(nextState.sort, sort); + const indexPatternChanged = !isEqual(nextState.index, index); // NOTE: this is also called when navigating from discover app to context app - if (nextState.index && state.index !== nextState.index) { - const nextIndexPattern = await loadIndexPattern( - nextState.index, - services.indexPatterns, - config - ); + if (nextState.index && indexPatternChanged) { + /** + * Without resetting the fetch state, e.g. a time column would be displayed when switching + * from a index pattern without to a index pattern with time filter for a brief moment + * That's because appState is updated before savedSearchData$ + * The following line of code catches this, but should be improved + */ + reset(); + const nextIndexPattern = await loadIndexPattern(nextState.index, indexPatterns, config); if (nextIndexPattern) { setIndexPattern(nextIndexPattern.loaded); } } + + if (chartDisplayChanged || chartIntervalChanged || docTableSortChanged) { + refetch$.next(); + } setState(nextState); }); return () => unsubscribe(); - }, [config, services.indexPatterns, state.index, stateContainer.appStateContainer, setState]); + }, [config, indexPatterns, appStateContainer, setState, state, refetch$, data$, reset]); const resetSavedSearch = useCallback( async (id?: string) => { @@ -143,13 +158,62 @@ export function useDiscoverState({ [services, indexPattern, config, data, stateContainer, savedSearch.id] ); + /** + * Function triggered when user changes index pattern in the sidebar + */ + const onChangeIndexPattern = useCallback( + async (id: string) => { + const nextIndexPattern = await indexPatterns.get(id); + if (nextIndexPattern && indexPattern) { + const nextAppState = getSwitchIndexPatternAppState( + indexPattern, + nextIndexPattern, + state.columns || [], + (state.sort || []) as SortPairArr[], + config.get(MODIFY_COLUMNS_ON_SWITCH), + config.get(SORT_DEFAULT_ORDER_SETTING) + ); + stateContainer.setAppState(nextAppState); + } + }, + [config, indexPattern, indexPatterns, state.columns, state.sort, stateContainer] + ); + /** + * Function triggered when the user changes the query in the search bar + */ + const onUpdateQuery = useCallback( + (_payload, isUpdate?: boolean) => { + if (isUpdate === false) { + searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); + refetch$.next(); + } + }, + [refetch$, searchSessionManager] + ); + + /** + * Initial data fetching, also triggered when index pattern changes + */ + useEffect(() => { + if (!indexPattern) { + return; + } + if (initialFetchStatus === FetchStatus.LOADING) { + refetch$.next(); + } + }, [initialFetchStatus, refetch$, indexPattern, data$]); + return { - state, - setState, - stateContainer, + data$, indexPattern, - searchSource, - savedSearch, + refetch$, resetSavedSearch, + onChangeIndexPattern, + onUpdateQuery, + savedSearch, + searchSource, + setState, + state, + stateContainer, }; } diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts index 5976c8fea5ea4..128c94f284f56 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts @@ -12,9 +12,10 @@ import { discoverServiceMock } from '../../../../__mocks__/services'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { useSavedSearch } from './use_saved_search'; -import { AppState, getState } from './discover_state'; +import { getState } from './discover_state'; import { uiSettingsMock } from '../../../../__mocks__/ui_settings'; import { useDiscoverState } from './use_discover_state'; +import { FetchStatus } from '../../../types'; describe('test useSavedSearch', () => { test('useSavedSearch return is valid', async () => { @@ -28,11 +29,10 @@ describe('test useSavedSearch', () => { const { result } = renderHook(() => { return useSavedSearch({ indexPattern: indexPatternMock, - savedSearch: savedSearchMock, + initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: savedSearchMock.searchSource.createCopy(), services: discoverServiceMock, - state: {} as AppState, stateContainer, useNewFieldsApi: true, }); @@ -69,11 +69,10 @@ describe('test useSavedSearch', () => { const { result, waitForValueToChange } = renderHook(() => { return useSavedSearch({ indexPattern: indexPatternMock, - savedSearch: savedSearchMock, + initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: resultState.current.searchSource, services: discoverServiceMock, - state: {} as AppState, stateContainer, useNewFieldsApi: true, }); @@ -88,4 +87,47 @@ describe('test useSavedSearch', () => { expect(result.current.data$.value.hits).toBe(0); expect(result.current.data$.value.rows).toEqual([]); }); + + test('reset sets back to initial state', async () => { + const { history, searchSessionManager } = createSearchSessionMock(); + const stateContainer = getState({ + getStateDefaults: () => ({ index: 'the-index-pattern-id' }), + history, + uiSettings: uiSettingsMock, + }); + + discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { + return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; + }); + + const { result: resultState } = renderHook(() => { + return useDiscoverState({ + services: discoverServiceMock, + history, + initialIndexPattern: indexPatternMock, + initialSavedSearch: savedSearchMock, + }); + }); + + const { result, waitForValueToChange } = renderHook(() => { + return useSavedSearch({ + indexPattern: indexPatternMock, + initialFetchStatus: FetchStatus.LOADING, + searchSessionManager, + searchSource: resultState.current.searchSource, + services: discoverServiceMock, + stateContainer, + useNewFieldsApi: true, + }); + }); + + result.current.refetch$.next(); + + await waitForValueToChange(() => { + return result.current.data$.value.state === FetchStatus.COMPLETE; + }); + + result.current.reset(); + expect(result.current.data$.value.state).toBe(FetchStatus.LOADING); + }); }); diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts index 2b0d951724869..8c847b54078eb 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts @@ -5,11 +5,10 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useEffect, useRef, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { merge, Subject, BehaviorSubject } from 'rxjs'; import { debounceTime, tap, filter } from 'rxjs/operators'; -import { isEqual } from 'lodash'; import { DiscoverServices } from '../../../../build_services'; import { DiscoverSearchSessionManager } from './discover_search_session'; import { @@ -18,13 +17,11 @@ import { SearchSource, tabifyAggResponse, } from '../../../../../../data/common'; -import { SavedSearch } from '../../../../saved_searches'; -import { AppState, GetStateReturn } from './discover_state'; +import { GetStateReturn } from './discover_state'; import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { RequestAdapter } from '../../../../../../inspector/public'; import { AutoRefreshDoneFn, search } from '../../../../../../data/public'; import { calcFieldCounts } from '../utils/calc_field_counts'; -import { SEARCH_ON_PAGE_LOAD_SETTING } from '../../../../../common'; import { validateTimeRange } from '../utils/validate_time_range'; import { updateSearchSource } from '../utils/update_search_source'; import { SortOrder } from '../../../../saved_searches/types'; @@ -40,6 +37,7 @@ export type SavedSearchRefetchSubject = Subject; export interface UseSavedSearch { refetch$: SavedSearchRefetchSubject; data$: SavedSearchDataSubject; + reset: () => void; } export type SavedSearchRefetchMsg = 'reset' | undefined; @@ -59,48 +57,27 @@ export interface SavedSearchDataMessage { /** * This hook return 2 observables, refetch$ allows to trigger data fetching, data$ to subscribe * to the data fetching - * @param indexPattern - * @param savedSearch - * @param searchSessionManager - * @param searchSource - * @param services - * @param state - * @param stateContainer - * @param useNewFieldsApi */ export const useSavedSearch = ({ indexPattern, - savedSearch, + initialFetchStatus, searchSessionManager, searchSource, services, - state, stateContainer, useNewFieldsApi, }: { indexPattern: IndexPattern; - savedSearch: SavedSearch; + initialFetchStatus: FetchStatus; searchSessionManager: DiscoverSearchSessionManager; searchSource: SearchSource; services: DiscoverServices; - state: AppState; stateContainer: GetStateReturn; useNewFieldsApi: boolean; }): UseSavedSearch => { - const { data, filterManager, uiSettings } = services; + const { data, filterManager } = services; const timefilter = data.query.timefilter.timefilter; - const initFetchState: FetchStatus = useMemo(() => { - // A saved search is created on every page load, so we check the ID to see if we're loading a - // previously saved search or if it is just transient - const shouldSearchOnPageLoad = - uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || - savedSearch.id !== undefined || - timefilter.getRefreshInterval().pause === false || - searchSessionManager.hasSearchSessionIdInURL(); - return shouldSearchOnPageLoad ? FetchStatus.LOADING : FetchStatus.UNINITIALIZED; - }, [uiSettings, savedSearch.id, searchSessionManager, timefilter]); - /** * The observable the UI (aka React component) subscribes to get notified about * the changes in the data fetching process (high level: fetching started, data was received) @@ -108,7 +85,7 @@ export const useSavedSearch = ({ const data$: SavedSearchDataSubject = useSingleton( () => new BehaviorSubject({ - state: initFetchState, + state: initialFetchStatus, }) ); /** @@ -123,15 +100,14 @@ export const useSavedSearch = ({ */ const refs = useRef<{ abortController?: AbortController; - /** - * used to compare a new state against an old one, to evaluate if data needs to be fetched - */ - appState: AppState; /** * handler emitted by `timefilter.getAutoRefreshFetch$()` * to notify when data completed loading and to start a new autorefresh loop */ autoRefreshDoneCb?: AutoRefreshDoneFn; + /** + * Number of fetches used for functional testing + */ fetchCounter: number; /** * needed to right auto refresh behavior, a new auto refresh shouldnt be triggered when @@ -144,12 +120,34 @@ export const useSavedSearch = ({ */ fieldCounts: Record; }>({ - appState: state, fetchCounter: 0, fieldCounts: {}, - fetchStatus: initFetchState, + fetchStatus: initialFetchStatus, }); + /** + * Resets the fieldCounts cache and sends a reset message + * It is set to initial state (no documents, fetchCounter to 0) + * Needed when index pattern is switched or a new runtime field is added + */ + const sendResetMsg = useCallback( + (fetchStatus?: FetchStatus) => { + refs.current.fieldCounts = {}; + refs.current.fetchStatus = fetchStatus ?? initialFetchStatus; + data$.next({ + state: initialFetchStatus, + fetchCounter: 0, + rows: [], + fieldCounts: {}, + chartData: undefined, + bucketInterval: undefined, + }); + }, + [data$, initialFetchStatus] + ); + /** + * Function to fetch data from ElasticSearch + */ const fetchAll = useCallback( (reset = false) => { if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) { @@ -161,23 +159,18 @@ export const useSavedSearch = ({ refs.current.abortController = new AbortController(); const sessionId = searchSessionManager.getNextSearchSessionId(); - // Let the UI know, data fetching started - const loadingMessage: SavedSearchDataMessage = { - state: FetchStatus.LOADING, - fetchCounter: ++refs.current.fetchCounter, - }; - if (reset) { - // when runtime field was added, changed, deleted, index pattern was switched - loadingMessage.rows = []; - loadingMessage.fieldCounts = {}; - loadingMessage.chartData = undefined; - loadingMessage.bucketInterval = undefined; + sendResetMsg(FetchStatus.LOADING); + } else { + // Let the UI know, data fetching started + data$.next({ + state: FetchStatus.LOADING, + fetchCounter: ++refs.current.fetchCounter, + }); + refs.current.fetchStatus = FetchStatus.LOADING; } - data$.next(loadingMessage); - refs.current.fetchStatus = loadingMessage.state; - const { sort } = stateContainer.appStateContainer.getState(); + const { sort, hideChart, interval } = stateContainer.appStateContainer.getState(); updateSearchSource(searchSource, false, { indexPattern, services, @@ -185,8 +178,8 @@ export const useSavedSearch = ({ useNewFieldsApi, }); const chartAggConfigs = - indexPattern.timeFieldName && !state.hideChart && state.interval - ? getChartAggConfigs(searchSource, state.interval, data) + indexPattern.timeFieldName && !hideChart && interval + ? getChartAggConfigs(searchSource, interval, data) : undefined; if (!chartAggConfigs) { @@ -217,16 +210,12 @@ export const useSavedSearch = ({ state: FetchStatus.COMPLETE, rows: documents, inspectorAdapters, - fieldCounts: calcFieldCounts( - reset ? {} : refs.current.fieldCounts, - documents, - indexPattern - ), + fieldCounts: calcFieldCounts(refs.current.fieldCounts, documents, indexPattern), hits: res.rawResponse.hits.total as number, }; if (chartAggConfigs) { - const bucketAggConfig = chartAggConfigs!.aggs[1]; + const bucketAggConfig = chartAggConfigs.aggs[1]; const tabifiedData = tabifyAggResponse(chartAggConfigs, res.rawResponse); const dimensions = getDimensions(chartAggConfigs, data); if (dimensions) { @@ -259,14 +248,13 @@ export const useSavedSearch = ({ [ timefilter, services, + searchSessionManager, stateContainer.appStateContainer, searchSource, indexPattern, useNewFieldsApi, - state.hideChart, - state.interval, data, - searchSessionManager, + sendResetMsg, data$, ] ); @@ -306,32 +294,9 @@ export const useSavedSearch = ({ fetchAll, ]); - /** - * Track state changes that should trigger a fetch - */ - useEffect(() => { - const prevAppState = refs.current.appState; - - // chart was hidden, now it should be displayed, so data is needed - const chartDisplayChanged = state.hideChart !== prevAppState.hideChart && !state.hideChart; - const chartIntervalChanged = state.interval !== prevAppState.interval; - const docTableSortChanged = !isEqual(state.sort, prevAppState.sort); - const indexPatternChanged = !isEqual(state.index, prevAppState.index); - - refs.current.appState = state; - if (chartDisplayChanged || chartIntervalChanged || docTableSortChanged || indexPatternChanged) { - refetch$.next(indexPatternChanged ? 'reset' : undefined); - } - }, [refetch$, state.interval, state.sort, state]); - - useEffect(() => { - if (initFetchState === FetchStatus.LOADING) { - refetch$.next(); - } - }, [initFetchState, refetch$]); - return { refetch$, data$, + reset: sendResetMsg, }; }; diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.test.tsx index 965d3cb6a30c4..de3c55ad7a869 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.test.tsx @@ -9,14 +9,21 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { findTestSubject } from '@elastic/eui/lib/test'; -import { FilterInBtn, FilterOutBtn } from './discover_grid_cell_actions'; +import { FilterInBtn, FilterOutBtn, buildCellActions } from './discover_grid_cell_actions'; import { DiscoverGridContext } from './discover_grid_context'; import { indexPatternMock } from '../../../__mocks__/index_pattern'; import { esHits } from '../../../__mocks__/es_hits'; import { EuiButton } from '@elastic/eui'; +import { IndexPatternField } from 'src/plugins/data/common'; describe('Discover cell actions ', function () { + it('should not show cell actions for unfilterable fields', async () => { + expect( + buildCellActions({ name: 'foo', filterable: false } as IndexPatternField) + ).toBeUndefined(); + }); + it('triggers filter function when FilterInBtn is clicked', async () => { const contextMock = { expanded: undefined, diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.tsx index 4e9218f0881cd..ab80cd3e7b461 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.tsx @@ -79,7 +79,7 @@ export const FilterOutBtn = ({ }; export function buildCellActions(field: IndexPatternField) { - if (!field.aggregatable && !field.searchable) { + if (!field.filterable) { return undefined; } diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx index 60841799b1398..50be2473a441e 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx @@ -144,7 +144,9 @@ describe('Discover flyout', function () { expect(props.setExpandedDoc.mock.calls[0][0]._id).toBe('4'); }); - it('allows navigating with arrow keys through documents', () => { + // EuiFlyout is mocked in Jest environments. + // EUI team to reinstate `onKeyDown`: https://github.com/elastic/eui/issues/4883 + it.skip('allows navigating with arrow keys through documents', () => { const props = getProps(); const component = mountWithIntl(); findTestSubject(component, 'docTableDetailsFlyout').simulate('keydown', { key: 'ArrowRight' }); diff --git a/src/plugins/discover/public/application/components/doc/doc.tsx b/src/plugins/discover/public/application/components/doc/doc.tsx index e38709b465174..ed8bcf30d2bd1 100644 --- a/src/plugins/discover/public/application/components/doc/doc.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.tsx @@ -10,9 +10,10 @@ import React from 'react'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent, EuiPage } from '@elastic/eui'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; +import { useEsDocSearch } from './use_es_doc_search'; import { getServices } from '../../../kibana_services'; import { DocViewer } from '../doc_viewer/doc_viewer'; +import { ElasticRequestState } from './elastic_request_state'; export interface DocProps { /** @@ -32,6 +33,10 @@ export interface DocProps { * IndexPatternService to get a given index pattern by ID */ indexPatternService: IndexPatternsContract; + /** + * If set, will always request source, regardless of the global `fieldsFromSource` setting + */ + requestSource?: boolean; } export function Doc(props: DocProps) { diff --git a/examples/url_generators_explorer/public/index.ts b/src/plugins/discover/public/application/components/doc/elastic_request_state.ts similarity index 75% rename from examples/url_generators_explorer/public/index.ts rename to src/plugins/discover/public/application/components/doc/elastic_request_state.ts index 8a78f3214453d..241e37c47a7e7 100644 --- a/examples/url_generators_explorer/public/index.ts +++ b/src/plugins/discover/public/application/components/doc/elastic_request_state.ts @@ -6,6 +6,10 @@ * Side Public License, v 1. */ -import { AccessLinksExplorerPlugin } from './plugin'; - -export const plugin = () => new AccessLinksExplorerPlugin(); +export enum ElasticRequestState { + Loading, + NotFound, + Found, + Error, + NotFoundIndexPattern, +} diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx index f3a6b274649f5..9fdb564cb518d 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx @@ -7,11 +7,12 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { buildSearchBody, useEsDocSearch, ElasticRequestState } from './use_es_doc_search'; +import { buildSearchBody, useEsDocSearch } from './use_es_doc_search'; import { DocProps } from './doc'; import { Observable } from 'rxjs'; import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../../../common'; import { IndexPattern } from 'src/plugins/data/common'; +import { ElasticRequestState } from './elastic_request_state'; const mockSearchResult = new Observable(); @@ -88,6 +89,36 @@ describe('Test of helper / hook', () => { `); }); + test('buildSearchBody with requestSource', () => { + const indexPattern = ({ + getComputedFields: () => ({ storedFields: [], scriptFields: [], docvalueFields: [] }), + } as unknown) as IndexPattern; + const actual = buildSearchBody('1', indexPattern, true, true); + expect(actual).toMatchInlineSnapshot(` + Object { + "body": Object { + "_source": true, + "fields": Array [ + Object { + "field": "*", + "include_unmapped": "true", + }, + ], + "query": Object { + "ids": Object { + "values": Array [ + "1", + ], + }, + }, + "runtime_mappings": Object {}, + "script_fields": Array [], + "stored_fields": Array [], + }, + } + `); + }); + test('buildSearchBody with runtime fields', () => { const indexPattern = ({ getComputedFields: () => ({ @@ -155,7 +186,11 @@ describe('Test of helper / hook', () => { await act(async () => { hook = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props }); }); - expect(hook.result.current).toEqual([ElasticRequestState.Loading, null, indexPattern]); + expect(hook.result.current.slice(0, 3)).toEqual([ + ElasticRequestState.Loading, + null, + indexPattern, + ]); expect(getMock).toHaveBeenCalled(); }); }); diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts index 7a3320d43c8b5..71a32b758aca7 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts @@ -6,23 +6,16 @@ * Side Public License, v 1. */ -import { useEffect, useState, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { estypes } from '@elastic/elasticsearch'; -import { IndexPattern, getServices } from '../../../kibana_services'; +import { getServices, IndexPattern } from '../../../kibana_services'; import { DocProps } from './doc'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; +import { ElasticRequestState } from './elastic_request_state'; type RequestBody = Pick; -export enum ElasticRequestState { - Loading, - NotFound, - Found, - Error, - NotFoundIndexPattern, -} - /** * helper function to build a query body for Elasticsearch * https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html @@ -30,7 +23,8 @@ export enum ElasticRequestState { export function buildSearchBody( id: string, indexPattern: IndexPattern, - useNewFieldsApi: boolean + useNewFieldsApi: boolean, + requestAllFields?: boolean ): RequestBody | undefined { const computedFields = indexPattern.getComputedFields(); const runtimeFields = computedFields.runtimeFields as estypes.MappingRuntimeFields; @@ -52,6 +46,9 @@ export function buildSearchBody( // @ts-expect-error request.body.fields = [{ field: '*', include_unmapped: 'true' }]; request.body.runtime_mappings = runtimeFields ? runtimeFields : {}; + if (requestAllFields) { + request.body._source = true; + } } else { request.body._source = true; } @@ -67,47 +64,50 @@ export function useEsDocSearch({ index, indexPatternId, indexPatternService, -}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null] { + requestSource, +}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null, () => void] { const [indexPattern, setIndexPattern] = useState(null); const [status, setStatus] = useState(ElasticRequestState.Loading); const [hit, setHit] = useState(null); const { data, uiSettings } = useMemo(() => getServices(), []); const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); - useEffect(() => { - async function requestData() { - try { - const indexPatternEntity = await indexPatternService.get(indexPatternId); - setIndexPattern(indexPatternEntity); + const requestData = useCallback(async () => { + try { + const indexPatternEntity = await indexPatternService.get(indexPatternId); + setIndexPattern(indexPatternEntity); - const { rawResponse } = await data.search - .search({ - params: { - index, - body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi)?.body, - }, - }) - .toPromise(); + const { rawResponse } = await data.search + .search({ + params: { + index, + body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi, requestSource)?.body, + }, + }) + .toPromise(); - const hits = rawResponse.hits; + const hits = rawResponse.hits; - if (hits?.hits?.[0]) { - setStatus(ElasticRequestState.Found); - setHit(hits.hits[0]); - } else { - setStatus(ElasticRequestState.NotFound); - } - } catch (err) { - if (err.savedObjectId) { - setStatus(ElasticRequestState.NotFoundIndexPattern); - } else if (err.status === 404) { - setStatus(ElasticRequestState.NotFound); - } else { - setStatus(ElasticRequestState.Error); - } + if (hits?.hits?.[0]) { + setStatus(ElasticRequestState.Found); + setHit(hits.hits[0]); + } else { + setStatus(ElasticRequestState.NotFound); + } + } catch (err) { + if (err.savedObjectId) { + setStatus(ElasticRequestState.NotFoundIndexPattern); + } else if (err.status === 404) { + setStatus(ElasticRequestState.NotFound); + } else { + setStatus(ElasticRequestState.Error); } } + }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi, requestSource]); + + useEffect(() => { requestData(); - }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi]); - return [status, hit, indexPattern]; + }, [requestData]); + + return [status, hit, indexPattern, requestData]; } diff --git a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap index 8f07614813495..31dd6347218b5 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap +++ b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap @@ -1,21 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`returns the \`JsonCodeEditor\` component 1`] = ` - - - -
    - - - -
    -
    - - - -
    + onEditorDidMount={[Function]} +/> `; diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss index 5521df5b363ac..335805ed28493 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss @@ -1,3 +1,3 @@ .dscJsonCodeEditor { - width: 100% + width: 100%; } diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx index b8427bb6bbdd2..f1ecd3ae3b70b 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx @@ -9,17 +9,8 @@ import './json_code_editor.scss'; import React, { useCallback } from 'react'; -import { i18n } from '@kbn/i18n'; -import { monaco, XJsonLang } from '@kbn/monaco'; -import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { CodeEditor } from '../../../../../kibana_react/public'; - -const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', { - defaultMessage: 'Read only JSON view of an elasticsearch document', -}); -const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', { - defaultMessage: 'Copy to clipboard', -}); +import { monaco } from '@kbn/monaco'; +import { JsonCodeEditorCommon } from './json_code_editor_common'; interface JsonCodeEditorProps { json: Record; @@ -47,45 +38,11 @@ export const JsonCodeEditor = ({ json, width, hasLineNumbers }: JsonCodeEditorPr }, []); return ( - - - -
    - - {(copy) => ( - - {copyToClipboardLabel} - - )} - -
    -
    - - {}} - editorDidMount={setEditorCalculatedHeight} - aria-label={codeEditorAriaLabel} - options={{ - automaticLayout: true, - fontSize: 12, - lineNumbers: hasLineNumbers ? 'on' : 'off', - minimap: { - enabled: false, - }, - overviewRulerBorder: false, - readOnly: true, - scrollbar: { - alwaysConsumeMouseWheel: false, - }, - scrollBeyondLastLine: false, - wordWrap: 'on', - wrappingIndent: 'indent', - }} - /> - -
    + ); }; diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx new file mode 100644 index 0000000000000..e5ab8bf4d1a0d --- /dev/null +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import './json_code_editor.scss'; + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { monaco, XJsonLang } from '@kbn/monaco'; +import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CodeEditor } from '../../../../../kibana_react/public'; + +const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', { + defaultMessage: 'Read only JSON view of an elasticsearch document', +}); +const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', { + defaultMessage: 'Copy to clipboard', +}); + +interface JsonCodeEditorCommonProps { + jsonValue: string; + onEditorDidMount: (editor: monaco.editor.IStandaloneCodeEditor) => void; + width?: string | number; + hasLineNumbers?: boolean; +} + +export const JsonCodeEditorCommon = ({ + jsonValue, + width, + hasLineNumbers, + onEditorDidMount, +}: JsonCodeEditorCommonProps) => { + if (jsonValue === '') { + return null; + } + return ( + + + +
    + + {(copy) => ( + + {copyToClipboardLabel} + + )} + +
    +
    + + {}} + editorDidMount={onEditorDidMount} + aria-label={codeEditorAriaLabel} + options={{ + automaticLayout: true, + fontSize: 12, + lineNumbers: hasLineNumbers ? 'on' : 'off', + minimap: { + enabled: false, + }, + overviewRulerBorder: false, + readOnly: true, + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + }} + /> + +
    + ); +}; + +export const JSONCodeEditorCommonMemoized = React.memo((props: JsonCodeEditorCommonProps) => { + return ; +}); diff --git a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap new file mode 100644 index 0000000000000..68786871825ac --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap @@ -0,0 +1,760 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Source Viewer component renders error state 1`] = ` + + + Could not fetch data at this time. Refresh the tab to try again. + + + Refresh + +
    + } + iconType="alert" + title={ +

    + An Error Occurred +

    + } + > +
    + + + + +
    + + +

    + An Error Occurred +

    +
    + + + +
    + + +
    +
    + Could not fetch data at this time. Refresh the tab to try again. + +
    + + + + + + +
    +
    + + + +
    + + +`; + +exports[`Source Viewer component renders json code editor 1`] = ` + + + + +
    + +
    + +
    + +
    + + + + + + + + + +
    +
    + + +
    + + + } + > + + + + + + +
    +
    +
    + + + + +`; + +exports[`Source Viewer component renders loading state 1`] = ` + +
    + + + + +
    + +
    + + Loading JSON + +
    +
    +
    +
    +
    +
    +`; diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss b/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss new file mode 100644 index 0000000000000..224e84ca50b52 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss @@ -0,0 +1,14 @@ +.sourceViewer__loading { + display: flex; + flex-direction: row; + justify-content: left; + flex: 1 0 100%; + text-align: center; + height: 100%; + width: 100%; + margin-top: $euiSizeS; +} + +.sourceViewer__loadingSpinner { + margin-right: $euiSizeS; +} diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx new file mode 100644 index 0000000000000..86433e5df6401 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { SourceViewer } from './source_viewer'; +import * as hooks from '../doc/use_es_doc_search'; +import * as useUiSettingHook from 'src/plugins/kibana_react/public/ui_settings/use_ui_setting'; +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { JsonCodeEditorCommon } from '../json_code_editor/json_code_editor_common'; + +jest.mock('../../../kibana_services', () => ({ + getServices: jest.fn(), +})); + +import { getServices, IndexPattern } from '../../../kibana_services'; + +const mockIndexPattern = { + getComputedFields: () => [], +} as never; +const getMock = jest.fn(() => Promise.resolve(mockIndexPattern)); +const mockIndexPatternService = ({ + get: getMock, +} as unknown) as IndexPattern; + +(getServices as jest.Mock).mockImplementation(() => ({ + uiSettings: { + get: (key: string) => { + if (key === 'discover:useNewFieldsApi') { + return true; + } + }, + }, + data: { + indexPatternService: mockIndexPatternService, + }, +})); +describe('Source Viewer component', () => { + test('renders loading state', () => { + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [0, null, null, () => {}]); + + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const loadingIndicator = comp.find(EuiLoadingSpinner); + expect(loadingIndicator).not.toBe(null); + }); + + test('renders error state', () => { + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [3, null, null, () => {}]); + + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const errorPrompt = comp.find(EuiEmptyPrompt); + expect(errorPrompt.length).toBe(1); + const refreshButton = comp.find(EuiButton); + expect(refreshButton.length).toBe(1); + }); + + test('renders json code editor', () => { + const mockHit = { + _index: 'logstash-2014.09.09', + _type: 'doc', + _id: 'id123', + _score: 1, + _source: { + message: 'Lorem ipsum dolor sit amet', + extension: 'html', + not_mapped: 'yes', + bytes: 100, + objectArray: [{ foo: true }], + relatedContent: { + test: 1, + }, + scripted: 123, + _underscore: 123, + }, + } as never; + jest + .spyOn(hooks, 'useEsDocSearch') + .mockImplementation(() => [2, mockHit, mockIndexPattern, () => {}]); + jest.spyOn(useUiSettingHook, 'useUiSetting').mockImplementation(() => { + return false; + }); + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const jsonCodeEditor = comp.find(JsonCodeEditorCommon); + expect(jsonCodeEditor).not.toBe(null); + }); +}); diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx new file mode 100644 index 0000000000000..94a12c04613a9 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import './source_viewer.scss'; + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { monaco } from '@kbn/monaco'; +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useEsDocSearch } from '../doc/use_es_doc_search'; +import { JSONCodeEditorCommonMemoized } from '../json_code_editor/json_code_editor_common'; +import { ElasticRequestState } from '../doc/elastic_request_state'; +import { getServices } from '../../../../public/kibana_services'; +import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; + +interface SourceViewerProps { + id: string; + index: string; + indexPatternId: string; + hasLineNumbers: boolean; + width?: number; +} + +export const SourceViewer = ({ + id, + index, + indexPatternId, + width, + hasLineNumbers, +}: SourceViewerProps) => { + const [editor, setEditor] = useState(); + const [jsonValue, setJsonValue] = useState(''); + const indexPatternService = getServices().data.indexPatterns; + const useNewFieldsApi = !getServices().uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); + const [reqState, hit, , requestData] = useEsDocSearch({ + id, + index, + indexPatternId, + indexPatternService, + requestSource: useNewFieldsApi, + }); + + useEffect(() => { + if (reqState === ElasticRequestState.Found && hit) { + setJsonValue(JSON.stringify(hit, undefined, 2)); + } + }, [reqState, hit]); + + // setting editor height based on lines height and count to stretch and fit its content + useEffect(() => { + if (!editor) { + return; + } + const editorElement = editor.getDomNode(); + + if (!editorElement) { + return; + } + + const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); + const lineCount = editor.getModel()?.getLineCount() || 1; + const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight; + if (!jsonValue || jsonValue === '') { + editorElement.style.height = '0px'; + } else { + editorElement.style.height = `${height}px`; + } + editor.layout(); + }, [editor, jsonValue]); + + const loadingState = ( +
    + + + + +
    + ); + + const errorMessageTitle = ( +

    + {i18n.translate('discover.sourceViewer.errorMessageTitle', { + defaultMessage: 'An Error Occurred', + })} +

    + ); + const errorMessage = ( +
    + {i18n.translate('discover.sourceViewer.errorMessage', { + defaultMessage: 'Could not fetch data at this time. Refresh the tab to try again.', + })} + + + {i18n.translate('discover.sourceViewer.refresh', { + defaultMessage: 'Refresh', + })} + +
    + ); + const errorState = ( + + ); + + if ( + reqState === ElasticRequestState.Error || + reqState === ElasticRequestState.NotFound || + reqState === ElasticRequestState.NotFoundIndexPattern + ) { + return errorState; + } + + if (reqState === ElasticRequestState.Loading || jsonValue === '') { + return loadingState; + } + + return ( + setEditor(editorNode)} + /> + ); +}; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index fbe853ec6deb5..3840df4353faf 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -17,4 +17,6 @@ export function plugin(initializerContext: PluginInitializerContext) { export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; export { loadSharingDataHelpers } from './shared'; + export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; +export { DiscoverAppLocator, DiscoverAppLocatorParams } from './locator'; diff --git a/src/plugins/discover/public/locator.test.ts b/src/plugins/discover/public/locator.test.ts new file mode 100644 index 0000000000000..edbb0663d4aa3 --- /dev/null +++ b/src/plugins/discover/public/locator.test.ts @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { hashedItemStore, getStatesFromKbnUrl } from '../../kibana_utils/public'; +import { mockStorage } from '../../kibana_utils/public/storage/hashed_item_store/mock'; +import { FilterStateStore } from '../../data/common'; +import { DiscoverAppLocatorDefinition } from './locator'; +import { SerializableState } from 'src/plugins/kibana_utils/common'; + +const indexPatternId: string = 'c367b774-a4c2-11ea-bb37-0242ac130002'; +const savedSearchId: string = '571aaf70-4c88-11e8-b3d7-01146121b73d'; + +interface SetupParams { + useHash?: boolean; +} + +const setup = async ({ useHash = false }: SetupParams = {}) => { + const locator = new DiscoverAppLocatorDefinition({ + useHash, + }); + + return { + locator, + }; +}; + +beforeEach(() => { + // @ts-expect-error + hashedItemStore.storage = mockStorage; +}); + +describe('Discover url generator', () => { + test('can create a link to Discover with no state and no saved search', async () => { + const { locator } = await setup(); + const { app, path } = await locator.getLocation({}); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(app).toBe('discover'); + expect(_a).toEqual({}); + expect(_g).toEqual({}); + }); + + test('can create a link to a saved search in Discover', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ savedSearchId }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(path.startsWith(`#/view/${savedSearchId}`)).toBe(true); + expect(_a).toEqual({}); + expect(_g).toEqual({}); + }); + + test('can specify specific index pattern', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + indexPatternId, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({ + index: indexPatternId, + }); + expect(_g).toEqual({}); + }); + + test('can specify specific time range', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + time: { + from: 'now-15m', + mode: 'relative', + to: 'now', + }, + }); + }); + + test('can specify query', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + expect(_g).toEqual({}); + }); + + test('can specify local and global filters', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + filters: [ + { + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + { + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + }, + ], + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({ + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + }, + ], + }); + expect(_g).toEqual({ + filters: [ + { + $state: { + store: 'globalState', + }, + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + }, + ], + }); + }); + + test('can set refresh interval', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + refreshInterval: { + pause: false, + value: 666, + }, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + refreshInterval: { + pause: false, + value: 666, + }, + }); + }); + + test('can set time range', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + timeRange: { + from: 'now-3h', + to: 'now', + }, + }); + const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + time: { + from: 'now-3h', + to: 'now', + }, + }); + }); + + test('can specify a search session id', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + searchSessionId: '__test__', + }); + + expect(path).toMatchInlineSnapshot(`"#/?_g=()&_a=()&searchSessionId=__test__"`); + expect(path).toContain('__test__'); + }); + + test('can specify columns, interval, sort and savedQuery', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + columns: ['_source'], + interval: 'auto', + sort: [['timestamp, asc']] as string[][] & SerializableState, + savedQuery: '__savedQueryId__', + }); + + expect(path).toMatchInlineSnapshot( + `"#/?_g=()&_a=(columns:!(_source),interval:auto,savedQuery:__savedQueryId__,sort:!(!('timestamp,%20asc')))"` + ); + }); + + describe('useHash property', () => { + describe('when default useHash is set to false', () => { + test('when using default, sets index pattern ID in the generated URL', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + indexPatternId, + }); + + expect(path.indexOf(indexPatternId) > -1).toBe(true); + }); + + test('when enabling useHash, does not set index pattern ID in the generated URL', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + useHash: true, + indexPatternId, + }); + + expect(path.indexOf(indexPatternId) > -1).toBe(false); + }); + }); + + describe('when default useHash is set to true', () => { + test('when using default, does not set index pattern ID in the generated URL', async () => { + const { locator } = await setup({ useHash: true }); + const { path } = await locator.getLocation({ + indexPatternId, + }); + + expect(path.indexOf(indexPatternId) > -1).toBe(false); + }); + + test('when disabling useHash, sets index pattern ID in the generated URL', async () => { + const { locator } = await setup({ useHash: true }); + const { path } = await locator.getLocation({ + useHash: false, + indexPatternId, + }); + + expect(path.indexOf(indexPatternId) > -1).toBe(true); + }); + }); + }); +}); diff --git a/src/plugins/discover/public/locator.ts b/src/plugins/discover/public/locator.ts new file mode 100644 index 0000000000000..fff89903bc465 --- /dev/null +++ b/src/plugins/discover/public/locator.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SerializableState } from 'src/plugins/kibana_utils/common'; +import type { TimeRange, Filter, Query, QueryState, RefreshInterval } from '../../data/public'; +import type { LocatorDefinition, LocatorPublic } from '../../share/public'; +import { esFilters } from '../../data/public'; +import { setStateToKbnUrl } from '../../kibana_utils/public'; + +export const DISCOVER_APP_LOCATOR = 'DISCOVER_APP_LOCATOR'; + +export interface DiscoverAppLocatorParams extends SerializableState { + /** + * Optionally set saved search ID. + */ + savedSearchId?: string; + + /** + * Optionally set index pattern ID. + */ + indexPatternId?: string; + + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval & SerializableState; + + /** + * Optionally apply filters. + */ + filters?: Filter[]; + + /** + * Optionally set a query. + */ + query?: Query; + + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; + + /** + * Background search session id + */ + searchSessionId?: string; + + /** + * Columns displayed in the table + */ + columns?: string[]; + + /** + * Used interval of the histogram + */ + interval?: string; + + /** + * Array of the used sorting [[field,direction],...] + */ + sort?: string[][] & SerializableState; + + /** + * id of the used saved query + */ + savedQuery?: string; +} + +export type DiscoverAppLocator = LocatorPublic; + +export interface DiscoverAppLocatorDependencies { + useHash: boolean; +} + +export class DiscoverAppLocatorDefinition implements LocatorDefinition { + public readonly id = DISCOVER_APP_LOCATOR; + + constructor(protected readonly deps: DiscoverAppLocatorDependencies) {} + + public readonly getLocation = async (params: DiscoverAppLocatorParams) => { + const { + useHash = this.deps.useHash, + filters, + indexPatternId, + query, + refreshInterval, + savedSearchId, + timeRange, + searchSessionId, + columns, + savedQuery, + sort, + interval, + } = params; + const savedSearchPath = savedSearchId ? `view/${encodeURIComponent(savedSearchId)}` : ''; + const appState: { + query?: Query; + filters?: Filter[]; + index?: string; + columns?: string[]; + interval?: string; + sort?: string[][]; + savedQuery?: string; + } = {}; + const queryState: QueryState = {}; + + if (query) appState.query = query; + if (filters && filters.length) + appState.filters = filters?.filter((f) => !esFilters.isFilterPinned(f)); + if (indexPatternId) appState.index = indexPatternId; + if (columns) appState.columns = columns; + if (savedQuery) appState.savedQuery = savedQuery; + if (sort) appState.sort = sort; + if (interval) appState.interval = interval; + + if (timeRange) queryState.time = timeRange; + if (filters && filters.length) + queryState.filters = filters?.filter((f) => esFilters.isFilterPinned(f)); + if (refreshInterval) queryState.refreshInterval = refreshInterval; + + let path = `#/${savedSearchPath}`; + path = setStateToKbnUrl('_g', queryState, { useHash }, path); + path = setStateToKbnUrl('_a', appState, { useHash }, path); + + if (searchSessionId) { + path = `${path}&searchSessionId=${searchSessionId}`; + } + + return { + app: 'discover', + path, + state: {}, + }; + }; +} diff --git a/src/plugins/discover/public/mocks.ts b/src/plugins/discover/public/mocks.ts index 0f57c5c0fa138..e2000e422f227 100644 --- a/src/plugins/discover/public/mocks.ts +++ b/src/plugins/discover/public/mocks.ts @@ -16,6 +16,16 @@ const createSetupContract = (): Setup => { docViews: { addDocView: jest.fn(), }, + locator: { + getLocation: jest.fn(), + getUrl: jest.fn(), + useUrl: jest.fn(), + navigate: jest.fn(), + extract: jest.fn(), + inject: jest.fn(), + telemetry: jest.fn(), + migrations: {}, + }, }; return setupContract; }; @@ -26,6 +36,16 @@ const createStartContract = (): Start => { urlGenerator: ({ createUrl: jest.fn(), } as unknown) as DiscoverStart['urlGenerator'], + locator: { + getLocation: jest.fn(), + getUrl: jest.fn(), + useUrl: jest.fn(), + navigate: jest.fn(), + extract: jest.fn(), + inject: jest.fn(), + telemetry: jest.fn(), + migrations: {}, + }, }; return startContract; }; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 139b23d28a1d4..ec89f7516e92d 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -37,7 +37,7 @@ import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; import { DocViewTable } from './application/components/table/table'; -import { JsonCodeEditor } from './application/components/json_code_editor/json_code_editor'; + import { setDocViewsRegistry, setUrlTracker, @@ -59,10 +59,12 @@ import { DiscoverUrlGenerator, SEARCH_SESSION_ID_QUERY_PARAM, } from './url_generator'; +import { DiscoverAppLocatorDefinition, DiscoverAppLocator } from './locator'; import { SearchEmbeddableFactory } from './application/embeddable'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { replaceUrlHashQuery } from '../../kibana_utils/public/'; import { IndexPatternFieldEditorStart } from '../../../plugins/index_pattern_field_editor/public'; +import { SourceViewer } from './application/components/source_viewer/source_viewer'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -82,17 +84,68 @@ export interface DiscoverSetup { */ addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; }; + + /** + * `share` plugin URL locator for Discover app. Use it to generate links into + * Discover application, for example, navigate: + * + * ```ts + * await plugins.discover.locator.navigate({ + * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', + * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', + * timeRange: { + * to: 'now', + * from: 'now-15m', + * mode: 'relative', + * }, + * }); + * ``` + * + * Generate a location: + * + * ```ts + * const location = await plugins.discover.locator.getLocation({ + * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', + * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', + * timeRange: { + * to: 'now', + * from: 'now-15m', + * mode: 'relative', + * }, + * }); + * ``` + */ + readonly locator: undefined | DiscoverAppLocator; } export interface DiscoverStart { savedSearchLoader: SavedObjectLoader; /** - * `share` plugin URL generator for Discover app. Use it to generate links into - * Discover application, example: + * @deprecated Use URL locator instead. URL generaotr will be removed. + */ + readonly urlGenerator: undefined | UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; + + /** + * `share` plugin URL locator for Discover app. Use it to generate links into + * Discover application, for example, navigate: * * ```ts - * const url = await plugins.discover.urlGenerator.createUrl({ + * await plugins.discover.locator.navigate({ + * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', + * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', + * timeRange: { + * to: 'now', + * from: 'now-15m', + * mode: 'relative', + * }, + * }); + * ``` + * + * Generate a location: + * + * ```ts + * const location = await plugins.discover.locator.getLocation({ * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', * timeRange: { @@ -103,7 +156,7 @@ export interface DiscoverStart { * }); * ``` */ - readonly urlGenerator: undefined | UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; + readonly locator: undefined | DiscoverAppLocator; } /** @@ -155,7 +208,12 @@ export class DiscoverPlugin private stopUrlTracking: (() => void) | undefined = undefined; private servicesInitialized: boolean = false; private innerAngularInitialized: boolean = false; + + /** + * @deprecated + */ private urlGenerator?: DiscoverStart['urlGenerator']; + private locator?: DiscoverAppLocator; /** * why are those functions public? they are needed for some mocha tests @@ -179,6 +237,14 @@ export class DiscoverPlugin ); } + if (plugins.share) { + this.locator = plugins.share.url.locators.create( + new DiscoverAppLocatorDefinition({ + useHash: core.uiSettings.get('state:storeInSessionStorage'), + }) + ); + } + this.docViewsRegistry = new DocViewsRegistry(); setDocViewsRegistry(this.docViewsRegistry); this.docViewsRegistry.addDocView({ @@ -193,8 +259,14 @@ export class DiscoverPlugin defaultMessage: 'JSON', }), order: 20, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: ({ hit }) => , + component: ({ hit, indexPattern }) => ( + + ), }); const { @@ -273,6 +345,7 @@ export class DiscoverPlugin // make sure the index pattern list is up to date await dataStart.indexPatterns.clearCache(); + const { renderApp } = await import('./application/application'); params.element.classList.add('dscAppWrapper'); const unmount = await renderApp(innerAngularName, params.element); @@ -316,6 +389,7 @@ export class DiscoverPlugin docViews: { addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), }, + locator: this.locator, }; } @@ -360,6 +434,7 @@ export class DiscoverPlugin return { urlGenerator: this.urlGenerator, + locator: this.locator, savedSearchLoader: createSavedSearchesLoader({ savedObjectsClient: core.savedObjects.client, savedObjects: plugins.savedObjects, diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 25d080dbfd546..80171e1ad2fab 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -69,6 +69,7 @@ export { EmbeddablePackageState, EmbeddableRenderer, EmbeddableRendererProps, + useEmbeddableFactory, } from './lib'; export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service'; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx index 457852c48ed77..b919672ad01e3 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx @@ -9,14 +9,39 @@ import React from 'react'; import { waitFor } from '@testing-library/dom'; import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; import { HelloWorldEmbeddable, HelloWorldEmbeddableFactoryDefinition, HELLO_WORLD_EMBEDDABLE, } from '../../tests/fixtures'; -import { EmbeddableRenderer } from './embeddable_renderer'; +import { EmbeddableRenderer, useEmbeddableFactory } from './embeddable_renderer'; import { embeddablePluginMock } from '../../mocks'; +describe('useEmbeddableFactory', () => { + it('should update upstream value changes', async () => { + const { setup, doStart } = embeddablePluginMock.createInstance(); + const getFactory = setup.registerEmbeddableFactory( + HELLO_WORLD_EMBEDDABLE, + new HelloWorldEmbeddableFactoryDefinition() + ); + doStart(); + + const { result, waitForNextUpdate } = renderHook(() => + useEmbeddableFactory({ factory: getFactory(), input: { id: 'hello' } }) + ); + + const [, loading] = result.current; + + expect(loading).toBe(true); + + await waitForNextUpdate(); + + const [embeddable] = result.current; + expect(embeddable).toBeDefined(); + }); +}); + describe('', () => { test('Render embeddable', () => { const embeddable = new HelloWorldEmbeddable({ id: 'hello' }); diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx index 153564187d4b5..433b21e92cce5 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx @@ -28,12 +28,6 @@ interface EmbeddableRendererPropsWithEmbeddable { embeddable: IEmbeddable; } -function isWithEmbeddable( - props: EmbeddableRendererProps -): props is EmbeddableRendererPropsWithEmbeddable { - return 'embeddable' in props; -} - interface EmbeddableRendererWithFactory { input: I; onInputUpdated?: (newInput: I) => void; @@ -46,6 +40,72 @@ function isWithFactory( return 'factory' in props; } +export function useEmbeddableFactory({ + input, + factory, + onInputUpdated, +}: EmbeddableRendererWithFactory) { + const [embeddable, setEmbeddable] = useState | ErrorEmbeddable | undefined>( + undefined + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const latestInput = React.useRef(input); + useEffect(() => { + latestInput.current = input; + }, [input]); + + useEffect(() => { + let canceled = false; + + // keeping track of embeddables created by this component to be able to destroy them + let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined; + setEmbeddable(undefined); + setLoading(true); + factory + .create(latestInput.current!) + .then((createdEmbeddable) => { + if (canceled) { + if (createdEmbeddable) { + createdEmbeddable.destroy(); + } + } else { + createdEmbeddableRef = createdEmbeddable; + setEmbeddable(createdEmbeddable); + } + }) + .catch((err) => { + if (canceled) return; + setError(err?.message); + }) + .finally(() => { + if (canceled) return; + setLoading(false); + }); + + return () => { + canceled = true; + if (createdEmbeddableRef) { + createdEmbeddableRef.destroy(); + } + }; + }, [factory]); + + useEffect(() => { + if (!embeddable) return; + if (isErrorEmbeddable(embeddable)) return; + if (!onInputUpdated) return; + const sub = embeddable.getInput$().subscribe((newInput) => { + onInputUpdated(newInput); + }); + return () => { + sub.unsubscribe(); + }; + }, [embeddable, onInputUpdated]); + + return [embeddable, loading, error] as const; +} + /** * Helper react component to render an embeddable * Can be used if you have an embeddable object or an embeddable factory @@ -82,72 +142,22 @@ function isWithFactory( export const EmbeddableRenderer = ( props: EmbeddableRendererProps ) => { - const { input, onInputUpdated } = props; - const [embeddable, setEmbeddable] = useState | ErrorEmbeddable | undefined>( - isWithEmbeddable(props) ? props.embeddable : undefined - ); - const [loading, setLoading] = useState(!isWithEmbeddable(props)); - const [error, setError] = useState(); - const latestInput = React.useRef(props.input); - useEffect(() => { - latestInput.current = input; - }, [input]); - - const factoryFromProps = isWithFactory(props) ? props.factory : undefined; - const embeddableFromProps = isWithEmbeddable(props) ? props.embeddable : undefined; - useEffect(() => { - let canceled = false; - if (embeddableFromProps) { - setEmbeddable(embeddableFromProps); - return; - } - - // keeping track of embeddables created by this component to be able to destroy them - let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined; - if (factoryFromProps) { - setEmbeddable(undefined); - setLoading(true); - factoryFromProps - .create(latestInput.current!) - .then((createdEmbeddable) => { - if (canceled) { - if (createdEmbeddable) { - createdEmbeddable.destroy(); - } - } else { - createdEmbeddableRef = createdEmbeddable; - setEmbeddable(createdEmbeddable); - } - }) - .catch((err) => { - if (canceled) return; - setError(err?.message); - }) - .finally(() => { - if (canceled) return; - setLoading(false); - }); - } - - return () => { - canceled = true; - if (createdEmbeddableRef) { - createdEmbeddableRef.destroy(); - } - }; - }, [factoryFromProps, embeddableFromProps]); - - useEffect(() => { - if (!embeddable) return; - if (isErrorEmbeddable(embeddable)) return; - if (!onInputUpdated) return; - const sub = embeddable.getInput$().subscribe((newInput) => { - onInputUpdated(newInput); - }); - return () => { - sub.unsubscribe(); - }; - }, [embeddable, onInputUpdated]); + if (isWithFactory(props)) { + return ; + } + return ; +}; +// +const EmbeddableByFactory = ({ + factory, + input, + onInputUpdated, +}: EmbeddableRendererWithFactory) => { + const [embeddable, loading, error] = useEmbeddableFactory({ + factory, + input, + onInputUpdated, + }); return ; }; diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 71dfd73e534e7..eede745f31794 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -16,4 +16,8 @@ export { ErrorEmbeddable, isErrorEmbeddable } from './error_embeddable'; export { withEmbeddableSubscription } from './with_subscription'; export { EmbeddableRoot } from './embeddable_root'; export * from '../../../common/lib/saved_object_embeddable'; -export { EmbeddableRenderer, EmbeddableRendererProps } from './embeddable_renderer'; +export { + EmbeddableRenderer, + EmbeddableRendererProps, + useEmbeddableFactory, +} from './embeddable_renderer'; diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 22001608f63ab..1b3e0388e9bb0 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -542,3 +542,40 @@ test('Check when hide header option is true', async () => { const title = findTestSubject(component, `embeddablePanelHeading-HelloAryaStark`); expect(title.length).toBe(0); }); + +test('Should work in minimal way rendering only the inspector action', async () => { + const inspector = inspectorPluginMock.createStartContract(); + inspector.isAvailable = jest.fn(() => true); + + const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, { + getEmbeddableFactory, + } as any); + + const embeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Arya', + lastName: 'Stark', + }); + + const component = mount( + + Promise.resolve([])} + inspector={inspector} + hideHeader={false} + /> + + ); + + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1); + await nextTick(); + component.update(); + expect(findTestSubject(component, `embeddablePanelAction-openInspector`).length).toBe(1); + const action = findTestSubject(component, `embeddablePanelAction-ACTION_CUSTOMIZE_PANEL`); + expect(action.length).toBe(0); +}); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 8cf2de8c80743..b66950c170d69 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -54,16 +54,20 @@ const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; - getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; - getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; - overlays: CoreStart['overlays']; - notifications: CoreStart['notifications']; - application: CoreStart['application']; - inspector: InspectorStartContract; - SavedObjectFinder: React.ComponentType; + getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory']; + getAllEmbeddableFactories?: EmbeddableStart['getEmbeddableFactories']; + overlays?: CoreStart['overlays']; + notifications?: CoreStart['notifications']; + application?: CoreStart['application']; + inspector?: InspectorStartContract; + SavedObjectFinder?: React.ComponentType; stateTransfer?: EmbeddableStateTransfer; hideHeader?: boolean; + actionPredicate?: (actionId: string) => boolean; reportUiCounter?: UsageCollectionStart['reportUiCounter']; + showShadow?: boolean; + showBadges?: boolean; + showNotifications?: boolean; } interface State { @@ -80,7 +84,11 @@ interface State { errorEmbeddable?: ErrorEmbeddable; } -interface PanelUniversalActions { +interface InspectorPanelAction { + inspectPanel: InspectPanelAction; +} + +interface BasePanelActions { customizePanelTitle: CustomizePanelTitleAction; addPanel: AddPanelAction; inspectPanel: InspectPanelAction; @@ -88,6 +96,15 @@ interface PanelUniversalActions { editPanel: EditPanelAction; } +const emptyObject = {}; +type EmptyObject = typeof emptyObject; + +type PanelUniversalActions = + | BasePanelActions + | InspectorPanelAction + | (BasePanelActions & InspectorPanelAction) + | EmptyObject; + export class EmbeddablePanel extends React.Component { private embeddableRoot: React.RefObject; private parentSubscription?: Subscription; @@ -117,10 +134,15 @@ export class EmbeddablePanel extends React.Component { } private async refreshBadges() { + if (!this.mounted) { + return; + } + if (this.props.showBadges === false) { + return; + } let badges = await this.props.getActions(PANEL_BADGE_TRIGGER, { embeddable: this.props.embeddable, }); - if (!this.mounted) return; const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { @@ -135,10 +157,15 @@ export class EmbeddablePanel extends React.Component { } private async refreshNotifications() { + if (!this.mounted) { + return; + } + if (this.props.showNotifications === false) { + return; + } let notifications = await this.props.getActions(PANEL_NOTIFICATION_TRIGGER, { embeddable: this.props.embeddable, }); - if (!this.mounted) return; const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { @@ -229,13 +256,18 @@ export class EmbeddablePanel extends React.Component { paddingSize="none" role="figure" aria-labelledby={headerId} + hasShadow={this.props.showShadow} > {!this.props.hideHeader && ( { }; private getUniversalActions = (): PanelUniversalActions => { + let actions = {}; + if (this.props.inspector) { + actions = { + inspectPanel: new InspectPanelAction(this.props.inspector), + }; + } + if ( + !this.props.getEmbeddableFactory || + !this.props.getAllEmbeddableFactories || + !this.props.overlays || + !this.props.notifications || + !this.props.SavedObjectFinder || + !this.props.application + ) { + return actions; + } + const createGetUserData = (overlays: OverlayStart) => async function getUserData(context: { embeddable: IEmbeddable }) { return new Promise<{ title: string | undefined; hideTitle?: boolean }>((resolve) => { @@ -308,6 +357,7 @@ export class EmbeddablePanel extends React.Component { // Universal actions are exposed on the context menu for every embeddable, they bypass the trigger // registry. return { + ...actions, customizePanelTitle: new CustomizePanelTitleAction(createGetUserData(this.props.overlays)), addPanel: new AddPanelAction( this.props.getEmbeddableFactory, @@ -317,7 +367,6 @@ export class EmbeddablePanel extends React.Component { this.props.SavedObjectFinder, this.props.reportUiCounter ), - inspectPanel: new InspectPanelAction(this.props.inspector), removePanel: new RemovePanelAction(), editPanel: new EditPanelAction( this.props.getEmbeddableFactory, @@ -338,9 +387,13 @@ export class EmbeddablePanel extends React.Component { regularActions = regularActions.filter(removeDisabledActions); } - const sortedActions = [...regularActions, ...Object.values(this.state.universalActions)].sort( - sortByOrderField - ); + let sortedActions = regularActions + .concat(Object.values(this.state.universalActions || {}) as Array>) + .sort(sortByOrderField); + + if (this.props.actionPredicate) { + sortedActions = sortedActions.filter(({ id }) => this.props.actionPredicate!(id)); + } return await buildContextMenuForActions({ actions: sortedActions.map((action) => ({ diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 403aa3e3f4c9f..742a2d1909941 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -36,7 +36,7 @@ export interface PanelHeaderProps { embeddable: IEmbeddable; headerId?: string; showPlaceholderTitle?: boolean; - customizeTitle: CustomizePanelTitleAction; + customizeTitle?: CustomizePanelTitleAction; } function renderBadges(badges: Array>, embeddable: IEmbeddable) { @@ -177,7 +177,7 @@ export function PanelHeader({ > {title || placeholderTitle} - ) : ( + ) : customizeTitle ? ( {title || placeholderTitle} - ); + ) : null; } return description ? ( ({ input, factory, onInputUpdated, }: EmbeddableRendererWithFactory): readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]; + // Warning: (ae-missing-release-tag) "VALUE_CLICK_TRIGGER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx index 0a27b4098681b..732aa35b05237 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx @@ -13,7 +13,7 @@ import { Error } from '../types'; interface Props { title: React.ReactNode; - error: Error; + error?: Error; actions?: JSX.Element; isCentered?: boolean; } @@ -32,30 +32,30 @@ export const PageError: React.FunctionComponent = ({ isCentered, ...rest }) => { - const { - error: errorString, - cause, // wrapEsError() on the server adds a "cause" array - message, - } = error; + const errorString = error?.error; + const cause = error?.cause; // wrapEsError() on the server adds a "cause" array + const message = error?.message; const errorContent = ( {title}

    } body={ - <> - {cause ? message || errorString :

    {message || errorString}

    } - {cause && ( - <> - -
      - {cause.map((causeMsg, i) => ( -
    • {causeMsg}
    • - ))} -
    - - )} - + error && ( + <> + {cause ? message || errorString :

    {message || errorString}

    } + {cause && ( + <> + +
      + {cause.map((causeMsg, i) => ( +
    • {causeMsg}
    • + ))} +
    + + )} + + ) } iconType="alert" actions={actions} diff --git a/packages/kbn-interpreter/src/common/lib/get_type.d.ts b/src/plugins/es_ui_shared/public/components/page_loading/index.ts similarity index 87% rename from packages/kbn-interpreter/src/common/lib/get_type.d.ts rename to src/plugins/es_ui_shared/public/components/page_loading/index.ts index 568658c780333..3e7b93bb4e7c3 100644 --- a/packages/kbn-interpreter/src/common/lib/get_type.d.ts +++ b/src/plugins/es_ui_shared/public/components/page_loading/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export declare function getType(node: any): string; +export { PageLoading } from './page_loading'; diff --git a/src/plugins/es_ui_shared/public/components/page_loading/page_loading.tsx b/src/plugins/es_ui_shared/public/components/page_loading/page_loading.tsx new file mode 100644 index 0000000000000..2fb99208e58ac --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/page_loading/page_loading.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText, EuiPageContent } from '@elastic/eui'; + +export const PageLoading: React.FunctionComponent = ({ children }) => { + return ( + + } + body={{children}} + data-test-subj="sectionLoading" + /> + + ); +}; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 7b9013c043a0e..ef2e2daa25468 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -17,6 +17,7 @@ import * as XJson from './xjson'; export { JsonEditor, OnJsonEditorUpdateHandler, JsonEditorState } from './components/json_editor'; +export { PageLoading } from './components/page_loading'; export { SectionLoading } from './components/section_loading'; export { Frequency, CronEditor } from './components/cron_editor'; diff --git a/src/plugins/home/common/instruction_variant.ts b/src/plugins/home/common/instruction_variant.ts index 310ee23460a08..f27b2c97bdc1e 100644 --- a/src/plugins/home/common/instruction_variant.ts +++ b/src/plugins/home/common/instruction_variant.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; + export const INSTRUCTION_VARIANT = { ESC: 'esc', OSX: 'osx', @@ -24,6 +26,7 @@ export const INSTRUCTION_VARIANT = { DOTNET: 'dotnet', LINUX: 'linux', PHP: 'php', + FLEET: 'fleet', }; const DISPLAY_MAP = { @@ -44,6 +47,9 @@ const DISPLAY_MAP = { [INSTRUCTION_VARIANT.DOTNET]: '.NET', [INSTRUCTION_VARIANT.LINUX]: 'Linux', [INSTRUCTION_VARIANT.PHP]: 'PHP', + [INSTRUCTION_VARIANT.FLEET]: i18n.translate('home.tutorial.instruction_variant.fleet', { + defaultMessage: 'Elastic APM (beta) in Fleet', + }), }; /** diff --git a/src/plugins/home/public/application/application.tsx b/src/plugins/home/public/application/application.tsx index 9ab720b47ab92..18f3089c14d11 100644 --- a/src/plugins/home/public/application/application.tsx +++ b/src/plugins/home/public/application/application.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; import { ScopedHistory, CoreStart } from 'kibana/public'; -import { KibanaContextProvider } from '../../../kibana_react/public'; +import { KibanaContextProvider, RedirectAppLinks } from '../../../kibana_react/public'; // @ts-ignore import { HomeApp } from './components/home_app'; import { getServices } from './kibana_services'; @@ -44,9 +44,11 @@ export const renderApp = async ( }); render( - - - , + + + + + , element ); diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap index b949fa7995d30..153438b34eb47 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap @@ -1,819 +1,707 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`home change home route should render a link to change the default route in advanced settings if advanced settings is enabled 1`] = ` -
    - + />, + "rightSideItems": Array [], } - /> -
    + - - - - - - - - -
    -
    + + + + + + + +