diff --git a/.ci/Jenkinsfile_baseline_capture b/.ci/Jenkinsfile_baseline_capture index 7fefbbb26fd12..d074da1cb1926 100644 --- a/.ci/Jenkinsfile_baseline_capture +++ b/.ci/Jenkinsfile_baseline_capture @@ -23,6 +23,7 @@ kibanaPipeline(timeoutMinutes: 210) { ) { withGcpServiceAccount.fromVaultSecret('secret/kibana-issues/dev/ci-artifacts-key', 'value') { withEnv([ + 'BUILD_TS_REFS_DISABLE=false', // disabled in root config so we need to override that here 'BUILD_TS_REFS_CACHE_ENABLE=true', 'BUILD_TS_REFS_CACHE_CAPTURE=true', 'DISABLE_BOOTSTRAP_VALIDATION=true', @@ -36,6 +37,7 @@ kibanaPipeline(timeoutMinutes: 210) { tasks([ kibanaPipeline.functionalTestProcess('oss-baseline', './test/scripts/jenkins_baseline.sh'), kibanaPipeline.functionalTestProcess('xpack-baseline', './test/scripts/jenkins_xpack_baseline.sh'), + kibanaPipeline.scriptTask('Check Public API Docs', 'test/scripts/checks/plugin_public_api_docs.sh'), ]) } } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 92e39c2e634e5..cae64a24ec2cd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -272,7 +272,7 @@ /x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/ @elastic/kibana-alerting-services /x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/ @elastic/kibana-alerting-services /docs/user/alerting/ @elastic/kibana-alerting-services -/docs/management/alerting/ @elastic/kibana-alerting-services +/docs/management/connectors/ @elastic/kibana-alerting-services #CC# /x-pack/plugins/stack_alerts @elastic/kibana-alerting-services # Enterprise Search @@ -312,6 +312,7 @@ /x-pack/plugins/console_extensions/ @elastic/es-ui /x-pack/plugins/grokdebugger/ @elastic/es-ui /x-pack/plugins/index_management/ @elastic/es-ui +/x-pack/plugins/license_api_guard/ @elastic/es-ui /x-pack/plugins/license_management/ @elastic/es-ui /x-pack/plugins/painless_lab/ @elastic/es-ui /x-pack/plugins/remote_clusters/ @elastic/es-ui diff --git a/.telemetryrc.json b/.telemetryrc.json index a408a5e2842f9..3b404f98af5cc 100644 --- a/.telemetryrc.json +++ b/.telemetryrc.json @@ -2,6 +2,8 @@ { "output": "src/plugins/telemetry/schema/oss_plugins.json", "root": "src/plugins/", - "exclude": [] + "exclude": [ + "src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.ts" + ] } ] diff --git a/config/kibana.yml b/config/kibana.yml index 7c7378fb5d29d..eefb6bb8bacda 100644 --- a/config/kibana.yml +++ b/config/kibana.yml @@ -23,7 +23,7 @@ #server.publicBaseUrl: "" # The maximum payload size in bytes for incoming server requests. -#server.maxPayloadBytes: 1048576 +#server.maxPayload: 1048576 # The Kibana server's name. This is used for display purposes. #server.name: "your-hostname" diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index c43b58d3aa989..f04aeb8420620 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -53,9 +53,14 @@ experimental[] Retrieve a paginated set of {kib} saved objects by various condit (Optional, object) Filters to objects that have a relationship with the type and ID combination. `filter`:: - (Optional, string) The filter is a KQL string with the caveat that if you filter with an attribute from your type saved object. - It should look like that savedObjectType.attributes.title: "myTitle". However, If you used a direct attribute of a saved object like `updatedAt`, - you will have to define your filter like that savedObjectType.updatedAt > 2018-12-22. + (Optional, string) The filter is a KQL string with the caveat that if you filter with an attribute from your saved object type, + it should look like that: `savedObjectType.attributes.title: "myTitle"`. However, If you use a root attribute of a saved + object such as `updated_at`, you will have to define your filter like that: `savedObjectType.updated_at > 2018-12-22`. + +`aggs`:: + (Optional, string) **experimental** An aggregation structure, serialized as a string. The field format is similar to `filter`, meaning + that to use a saved object type attribute in the aggregation, the `savedObjectType.attributes.title`: "myTitle"` format + must be used. For root fields, the syntax is `savedObjectType.rootField` NOTE: As objects change in {kib}, the results on each page of the response also change. Use the find API for traditional paginated results, but avoid using it to export large amounts of data. diff --git a/docs/apm/agent-configuration.asciidoc b/docs/apm/agent-configuration.asciidoc index aaaca867a5a01..2574d254ac14c 100644 --- a/docs/apm/agent-configuration.asciidoc +++ b/docs/apm/agent-configuration.asciidoc @@ -46,7 +46,7 @@ Go Agent:: {apm-go-ref}/configuration.html[Configuration reference] Java Agent:: {apm-java-ref}/configuration.html[Configuration reference] .NET Agent:: {apm-dotnet-ref}/configuration.html[Configuration reference] Node.js Agent:: {apm-node-ref}/configuration.html[Configuration reference] -PHP Agent:: _Not yet supported_ +PHP Agent:: {apm-php-ref}/configuration.html[Configuration reference] Python Agent:: {apm-py-ref}/configuration.html[Configuration reference] Ruby Agent:: {apm-ruby-ref}/configuration.html[Configuration reference] Real User Monitoring (RUM) Agent:: {apm-rum-ref}/configuration.html[Configuration reference] diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index d5fe7ebf47038..5ab0581201959 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -3,6 +3,21 @@ Get started building your own plugins, or contributing directly to the {kib} repo. +[discrete] +[[developing-on-windows]] +=== Developing on Windows + +In order to support Windows development we currently require you to use one of the following: + +- https://git-scm.com/download/win[Git bash] (other bash emulators like https://cmder.net/[Cmder] could work but we did not test them) +- https://docs.microsoft.com/en-us/windows/wsl/about[WSL] + +As well as installing https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015]. + +Before running the steps listed below, please make sure you have installed everything +that we require and listed above and that you are running the mentioned commands +through Git bash or WSL. + [discrete] [[get-kibana-code]] === Get the code diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index bc47e46f6763b..9564087dabefe 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -63,6 +63,7 @@ yarn kbn watch-bazel - @elastic/datemath - @kbn/apm-utils +- @kbn/babel-preset - @kbn/config-schema - @kbn/tinymath - @kbn/utility-types diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 353a77527d1d5..64a62e3656784 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -392,6 +392,10 @@ actitivies. |The features plugin enhance Kibana with a per-feature privilege system. +|{kib-repo}blob/{branch}/x-pack/plugins/file_data_visualizer[fileDataVisualizer] +|WARNING: Missing README. + + |{kib-repo}blob/{branch}/x-pack/plugins/file_upload[fileUpload] |WARNING: Missing README. @@ -444,6 +448,10 @@ the infrastructure monitoring use-case within Kibana. |Visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. +|{kib-repo}blob/{branch}/x-pack/plugins/license_api_guard/README.md[licenseApiGuard] +|This plugin is used by ES UI plugins to reject API requests when the plugin is unsupported by the user's license. + + |{kib-repo}blob/{branch}/x-pack/plugins/license_management/README.md[licenseManagement] |This plugin enables users to activate a trial license, downgrade to Basic, and upload a new license. 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 535bd8f11236d..3a383ee72b86a 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,9 @@ ```typescript readonly links: { + readonly canvas: { + readonly guide: string; + }; readonly dashboard: { readonly guide: string; readonly drilldowns: 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 b8d0d2288993e..c5bf4babd9da9 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 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 indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: 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;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: 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;
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>;
} | | +| [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 indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: 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;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: 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;
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>;
} | | diff --git a/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.openinnewtab.md b/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.openinnewtab.md new file mode 100644 index 0000000000000..4609fa68b3824 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.navigatetoappoptions.openinnewtab.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [NavigateToAppOptions](./kibana-plugin-core-public.navigatetoappoptions.md) > [openInNewTab](./kibana-plugin-core-public.navigatetoappoptions.openinnewtab.md) + +## NavigateToAppOptions.openInNewTab property + +if true, will open the app in new tab, will share session information via window.open if base + +Signature: + +```typescript +openInNewTab?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.find.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.find.md index ddd8b207e3d78..fc9652b96450f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.find.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.find.md @@ -9,5 +9,5 @@ Search for objects Signature: ```typescript -find: (options: SavedObjectsFindOptions) => Promise>; +find: (options: SavedObjectsFindOptions) => Promise>; ``` 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 6e53b169b8bed..1ec756f8d743d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md @@ -24,7 +24,7 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkGet](./kibana-plugin-core-public.savedobjectsclient.bulkget.md) | | (objects?: Array<{
id: string;
type: string;
}>) => Promise<SavedObjectsBatchResponse<unknown>> | Returns an array of objects by id | | [create](./kibana-plugin-core-public.savedobjectsclient.create.md) | | <T = unknown>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | | [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>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | +| [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 | ## Methods diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md new file mode 100644 index 0000000000000..14401b02f25c7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindResponsePublic](./kibana-plugin-core-public.savedobjectsfindresponsepublic.md) > [aggregations](./kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md) + +## SavedObjectsFindResponsePublic.aggregations property + +Signature: + +```typescript +aggregations?: A; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.md index 7d75878041264..6f2276194f054 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindresponsepublic.md @@ -11,13 +11,14 @@ Return type of the Saved Objects `find()` method. Signature: ```typescript -export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse +export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse ``` ## Properties | Property | Type | Description | | --- | --- | --- | +| [aggregations](./kibana-plugin-core-public.savedobjectsfindresponsepublic.aggregations.md) | A | | | [page](./kibana-plugin-core-public.savedobjectsfindresponsepublic.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindresponsepublic.perpage.md) | number | | | [total](./kibana-plugin-core-public.savedobjectsfindresponsepublic.total.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.cspconfig.disableembedding.md b/docs/development/core/server/kibana-plugin-core-server.cspconfig.disableembedding.md new file mode 100644 index 0000000000000..bbd7c42c302d1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.cspconfig.disableembedding.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CspConfig](./kibana-plugin-core-server.cspconfig.md) > [disableEmbedding](./kibana-plugin-core-server.cspconfig.disableembedding.md) + +## CspConfig.disableEmbedding property + +Signature: + +```typescript +readonly disableEmbedding: boolean; +``` 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 6ff807929e6fb..9f4f3211ea2b1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.cspconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.cspconfig.md @@ -21,6 +21,7 @@ The constructor for this class is marked as internal. Third-party code should no | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [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 | | | [rules](./kibana-plugin-core-server.cspconfig.rules.md) | | string[] | | | [strict](./kibana-plugin-core-server.cspconfig.strict.md) | | boolean | | diff --git a/docs/development/core/server/kibana-plugin-core-server.icspconfig.disableembedding.md b/docs/development/core/server/kibana-plugin-core-server.icspconfig.disableembedding.md new file mode 100644 index 0000000000000..2cfd680459fbc --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.icspconfig.disableembedding.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ICspConfig](./kibana-plugin-core-server.icspconfig.md) > [disableEmbedding](./kibana-plugin-core-server.icspconfig.disableembedding.md) + +## ICspConfig.disableEmbedding property + +Whether or not embedding (using iframes) should be allowed by the CSP. If embedding is disabled \*and\* no custom rules have been defined, a restrictive 'frame-ancestors' rule will be added to the default CSP rules. + +Signature: + +```typescript +readonly disableEmbedding: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.icspconfig.md b/docs/development/core/server/kibana-plugin-core-server.icspconfig.md index bf04b760a0636..ee49950df076c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.icspconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.icspconfig.md @@ -16,6 +16,7 @@ export interface ICspConfig | Property | Type | Description | | --- | --- | --- | +| [disableEmbedding](./kibana-plugin-core-server.icspconfig.disableembedding.md) | boolean | Whether or not embedding (using iframes) should be allowed by the CSP. If embedding is disabled \*and\* no custom rules have been defined, a restrictive 'frame-ancestors' rule will be added to the default CSP rules. | | [header](./kibana-plugin-core-server.icspconfig.header.md) | string | The CSP rules in a formatted directives string for use in a Content-Security-Policy header. | | [rules](./kibana-plugin-core-server.icspconfig.rules.md) | string[] | The CSP rules used for Kibana. | | [strict](./kibana-plugin-core-server.icspconfig.strict.md) | boolean | Specify whether browsers that do not support CSP should be able to use Kibana. Use true to block and false to allow. | diff --git a/docs/development/core/server/kibana-plugin-core-server.makeusagefromschema.md b/docs/development/core/server/kibana-plugin-core-server.makeusagefromschema.md new file mode 100644 index 0000000000000..f47d01a2d09e8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.makeusagefromschema.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [MakeUsageFromSchema](./kibana-plugin-core-server.makeusagefromschema.md) + +## MakeUsageFromSchema type + +List of configuration values that will be exposed to usage collection. If parent node or actual config path is set to `true` then the actual value of these configs will be reoprted. If parent node or actual config path is set to `false` then the config will be reported as \[redacted\]. + +Signature: + +```typescript +export declare type MakeUsageFromSchema = { + [Key in keyof T]?: T[Key] extends Maybe ? false : T[Key] extends Maybe ? boolean : T[Key] extends Maybe ? MakeUsageFromSchema | boolean : boolean; +}; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 3bbdf8c703ab1..e33e9472d42a9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -272,6 +272,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [LegacyElasticsearchClientConfig](./kibana-plugin-core-server.legacyelasticsearchclientconfig.md) | | | [LifecycleResponseFactory](./kibana-plugin-core-server.lifecycleresponsefactory.md) | Creates an object containing redirection or error response with error details, HTTP headers, and other data transmitted to the client. | | [LoggerConfigType](./kibana-plugin-core-server.loggerconfigtype.md) | | +| [MakeUsageFromSchema](./kibana-plugin-core-server.makeusagefromschema.md) | List of configuration values that will be exposed to usage collection. If parent node or actual config path is set to true then the actual value of these configs will be reoprted. If parent node or actual config path is set to false then the config will be reported as \[redacted\]. | | [MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md) | APIs to retrieves metrics gathered and exposed by the core platform. | | [MIGRATION\_ASSISTANCE\_INDEX\_ACTION](./kibana-plugin-core-server.migration_assistance_index_action.md) | | | [MIGRATION\_DEPRECATION\_LEVEL](./kibana-plugin-core-server.migration_deprecation_level.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.exposetousage.md b/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.exposetousage.md new file mode 100644 index 0000000000000..8c50c2e339426 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.exposetousage.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [PluginConfigDescriptor](./kibana-plugin-core-server.pluginconfigdescriptor.md) > [exposeToUsage](./kibana-plugin-core-server.pluginconfigdescriptor.exposetousage.md) + +## PluginConfigDescriptor.exposeToUsage property + +Expose non-default configs to usage collection to be sent via telemetry. set a config to `true` to report the actual changed config value. set a config to `false` to report the changed config value as \[redacted\]. + +All changed configs except booleans and numbers will be reported as \[redacted\] unless otherwise specified. + +[MakeUsageFromSchema](./kibana-plugin-core-server.makeusagefromschema.md) + +Signature: + +```typescript +exposeToUsage?: MakeUsageFromSchema; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md b/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md index 5708c4f9a3f88..80e807a1361fd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md +++ b/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md @@ -46,5 +46,6 @@ export const config: PluginConfigDescriptor = { | --- | --- | --- | | [deprecations](./kibana-plugin-core-server.pluginconfigdescriptor.deprecations.md) | ConfigDeprecationProvider | Provider for the to apply to the plugin configuration. | | [exposeToBrowser](./kibana-plugin-core-server.pluginconfigdescriptor.exposetobrowser.md) | {
[P in keyof T]?: boolean;
} | List of configuration properties that will be available on the client-side plugin. | +| [exposeToUsage](./kibana-plugin-core-server.pluginconfigdescriptor.exposetousage.md) | MakeUsageFromSchema<T> | Expose non-default configs to usage collection to be sent via telemetry. set a config to true to report the actual changed config value. set a config to false to report the changed config value as \[redacted\].All changed configs except booleans and numbers will be reported as \[redacted\] unless otherwise specified.[MakeUsageFromSchema](./kibana-plugin-core-server.makeusagefromschema.md) | | [schema](./kibana-plugin-core-server.pluginconfigdescriptor.schema.md) | PluginConfigSchema<T> | Schema to use to validate the plugin configuration.[PluginConfigSchema](./kibana-plugin-core-server.pluginconfigschema.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptionsbody.maxbytes.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptionsbody.maxbytes.md index 474f7092b1b39..4d2641255adb4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptionsbody.maxbytes.md +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptionsbody.maxbytes.md @@ -6,7 +6,7 @@ Limits the size of incoming payloads to the specified byte count. Allowing very large payloads may cause the server to run out of memory. -Default value: The one set in the kibana.yml config file under the parameter `server.maxPayloadBytes`. +Default value: The one set in the kibana.yml config file under the parameter `server.maxPayload`. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptionsbody.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptionsbody.md index 330935e42026a..d27c67891161a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptionsbody.md +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptionsbody.md @@ -17,7 +17,7 @@ export interface RouteConfigOptionsBody | Property | Type | Description | | --- | --- | --- | | [accepts](./kibana-plugin-core-server.routeconfigoptionsbody.accepts.md) | RouteContentType | RouteContentType[] | string | string[] | A string or an array of strings with the allowed mime types for the endpoint. Use this settings to limit the set of allowed mime types. Note that allowing additional mime types not listed above will not enable them to be parsed, and if parse is true, the request will result in an error response.Default value: allows parsing of the following mime types: \* application/json \* application/\*+json \* application/octet-stream \* application/x-www-form-urlencoded \* multipart/form-data \* text/\* | -| [maxBytes](./kibana-plugin-core-server.routeconfigoptionsbody.maxbytes.md) | number | Limits the size of incoming payloads to the specified byte count. Allowing very large payloads may cause the server to run out of memory.Default value: The one set in the kibana.yml config file under the parameter server.maxPayloadBytes. | +| [maxBytes](./kibana-plugin-core-server.routeconfigoptionsbody.maxbytes.md) | number | Limits the size of incoming payloads to the specified byte count. Allowing very large payloads may cause the server to run out of memory.Default value: The one set in the kibana.yml config file under the parameter server.maxPayload. | | [output](./kibana-plugin-core-server.routeconfigoptionsbody.output.md) | typeof validBodyOutput[number] | The processed payload format. The value must be one of: \* 'data' - the incoming payload is read fully into memory. If parse is true, the payload is parsed (JSON, form-decoded, multipart) based on the 'Content-Type' header. If parse is false, a raw Buffer is returned. \* 'stream' - the incoming payload is made available via a Stream.Readable interface. If the payload is 'multipart/form-data' and parse is true, field values are presented as text while files are provided as streams. File streams from a 'multipart/form-data' upload will also have a hapi property containing the filename and headers properties. Note that payload streams for multipart payloads are a synthetic interface created on top of the entire multipart content loaded into memory. To avoid loading large multipart payloads into memory, set parse to false and handle the multipart payload in the handler using a streaming parser (e.g. pez).Default value: 'data', unless no validation.body is provided in the route definition. In that case the default is 'stream' to alleviate memory pressure. | | [parse](./kibana-plugin-core-server.routeconfigoptionsbody.parse.md) | boolean | 'gunzip' | Determines if the incoming payload is processed or presented raw. Available values: \* true - if the request 'Content-Type' matches the allowed mime types set by allow (for the whole payload as well as parts), the payload is converted into an object when possible. If the format is unknown, a Bad Request (400) error response is sent. Any known content encoding is decoded. \* false - the raw payload is returned unmodified. \* 'gunzip' - the raw payload is returned unmodified after any known content encoding is decoded.Default value: true, unless no validation.body is provided in the route definition. In that case the default is false to alleviate memory pressure. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.find.md index 9a4c3df5d2d92..56d76125108d1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.find.md @@ -9,7 +9,7 @@ Find all SavedObjects matching the search query Signature: ```typescript -find(options: SavedObjectsFindOptions): Promise>; +find(options: SavedObjectsFindOptions): Promise>; ``` ## Parameters @@ -20,5 +20,5 @@ find(options: SavedObjectsFindOptions): PromiseReturns: -`Promise>` +`Promise>` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md new file mode 100644 index 0000000000000..17a899f4c8280 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) > [aggregations](./kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md) + +## SavedObjectsFindResponse.aggregations property + +Signature: + +```typescript +aggregations?: A; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md index fd56e8ce40e24..8176baf44acbd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md @@ -11,13 +11,14 @@ Return type of the Saved Objects `find()` method. Signature: ```typescript -export interface SavedObjectsFindResponse +export interface SavedObjectsFindResponse ``` ## Properties | Property | Type | Description | | --- | --- | --- | +| [aggregations](./kibana-plugin-core-server.savedobjectsfindresponse.aggregations.md) | A | | | [page](./kibana-plugin-core-server.savedobjectsfindresponse.page.md) | number | | | [per\_page](./kibana-plugin-core-server.savedobjectsfindresponse.per_page.md) | number | | | [pit\_id](./kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsmigrationlogger.error.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsmigrationlogger.error.md index 7536cd2b07ae6..16fbc8f4eaea3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsmigrationlogger.error.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsmigrationlogger.error.md @@ -7,5 +7,5 @@ Signature: ```typescript -error: (msg: string, meta: LogMeta) => void; +error: (msg: string, meta: Meta) => void; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsmigrationlogger.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsmigrationlogger.md index 1b691ee8cb16d..697f8823c4966 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsmigrationlogger.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsmigrationlogger.md @@ -16,7 +16,7 @@ export interface SavedObjectsMigrationLogger | Property | Type | Description | | --- | --- | --- | | [debug](./kibana-plugin-core-server.savedobjectsmigrationlogger.debug.md) | (msg: string) => void | | -| [error](./kibana-plugin-core-server.savedobjectsmigrationlogger.error.md) | (msg: string, meta: LogMeta) => void | | +| [error](./kibana-plugin-core-server.savedobjectsmigrationlogger.error.md) | <Meta extends LogMeta = LogMeta>(msg: string, meta: Meta) => void | | | [info](./kibana-plugin-core-server.savedobjectsmigrationlogger.info.md) | (msg: string) => void | | | [warn](./kibana-plugin-core-server.savedobjectsmigrationlogger.warn.md) | (msg: string) => void | | | [warning](./kibana-plugin-core-server.savedobjectsmigrationlogger.warning.md) | (msg: string) => void | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md index d3e93e7af2aa0..5c823b7567918 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md @@ -7,7 +7,7 @@ Signature: ```typescript -find(options: SavedObjectsFindOptions): Promise>; +find(options: SavedObjectsFindOptions): Promise>; ``` ## Parameters @@ -18,7 +18,7 @@ find(options: SavedObjectsFindOptions): PromiseReturns: -`Promise>` +`Promise>` {promise} - { saved\_objects: \[{ id, type, version, attributes }\], total, per\_page, page } diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md index 40e865cb02ce8..23cbebf22aa21 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md @@ -9,5 +9,5 @@ Creates an empty response for a find operation. This is only intended to be used Signature: ```typescript -static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; +static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md index 8c787364c4cbe..0148621e757b7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md @@ -15,7 +15,7 @@ export declare class SavedObjectsUtils | Property | Modifiers | Type | Description | | --- | --- | --- | --- | -| [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) | static | <T>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T> | Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. | +| [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) | static | <T, A>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T, A> | Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. | | [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | static | (namespace?: string | undefined) => string | Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the undefined namespace ID (which has a namespace string of 'default'). | | [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | static | (namespace: string) => string | undefined | Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the 'default' namespace string (which has a namespace ID of undefined). | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md new file mode 100644 index 0000000000000..66d540c48c3bc --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [hierarchical](./kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md) + +## AggConfigs.hierarchical property + +Signature: + +```typescript +hierarchical?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md index 22f8994747aa2..02e9a63d95ba3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md @@ -22,6 +22,7 @@ export declare class AggConfigs | --- | --- | --- | --- | | [aggs](./kibana-plugin-plugins-data-public.aggconfigs.aggs.md) | | IAggConfig[] | | | [createAggConfig](./kibana-plugin-plugins-data-public.aggconfigs.createaggconfig.md) | | <T extends AggConfig = AggConfig>(params: CreateAggConfigParams, { addToAggConfigs }?: {
addToAggConfigs?: boolean | undefined;
}) => T | | +| [hierarchical](./kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md) | | boolean | | | [indexPattern](./kibana-plugin-plugins-data-public.aggconfigs.indexpattern.md) | | IndexPattern | | | [timeFields](./kibana-plugin-plugins-data-public.aggconfigs.timefields.md) | | string[] | | | [timeRange](./kibana-plugin-plugins-data-public.aggconfigs.timerange.md) | | TimeRange | | @@ -46,5 +47,5 @@ export declare class AggConfigs | [onSearchRequestStart(searchSource, options)](./kibana-plugin-plugins-data-public.aggconfigs.onsearchrequeststart.md) | | | | [setTimeFields(timeFields)](./kibana-plugin-plugins-data-public.aggconfigs.settimefields.md) | | | | [setTimeRange(timeRange)](./kibana-plugin-plugins-data-public.aggconfigs.settimerange.md) | | | -| [toDsl(hierarchical)](./kibana-plugin-plugins-data-public.aggconfigs.todsl.md) | | | +| [toDsl()](./kibana-plugin-plugins-data-public.aggconfigs.todsl.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.todsl.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.todsl.md index 055c4113ca3e4..1327e976db0ce 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.todsl.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.todsl.md @@ -7,15 +7,8 @@ Signature: ```typescript -toDsl(hierarchical?: boolean): Record; +toDsl(): Record; ``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| hierarchical | boolean | | - Returns: `Record` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquerysortvalue.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquerysortvalue.md index 83762c22f0f82..15f45532cce2f 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquerysortvalue.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquerysortvalue.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type EsQuerySortValue = Record; +export declare type EsQuerySortValue = Record; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.inspector.md similarity index 52% rename from docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md rename to docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.inspector.md index b4431b9467b71..9961292aaf217 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.inspector.md @@ -1,11 +1,13 @@ -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) > [requestResponder](./kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md) +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) > [inspector](./kibana-plugin-plugins-data-public.isearchoptions.inspector.md) -## ISearchOptions.requestResponder property +## ISearchOptions.inspector property + +Inspector integration options Signature: ```typescript -requestResponder?: RequestResponder; +inspector?: IInspectorInfo; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md index cc0cb538be611..21fb7e3dfc7e8 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md @@ -16,10 +16,10 @@ export interface ISearchOptions | --- | --- | --- | | [abortSignal](./kibana-plugin-plugins-data-public.isearchoptions.abortsignal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | | [indexPattern](./kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md) | IndexPattern | Index pattern reference is used for better error messages | +| [inspector](./kibana-plugin-plugins-data-public.isearchoptions.inspector.md) | IInspectorInfo | Inspector integration options | | [isRestore](./kibana-plugin-plugins-data-public.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-public.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-public.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | -| [requestResponder](./kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md) | RequestResponder | | | [sessionId](./kibana-plugin-plugins-data-public.isearchoptions.sessionid.md) | string | A session ID, grouping multiple search requests into a single session. | | [strategy](./kibana-plugin-plugins-data-public.isearchoptions.strategy.md) | string | Use this option to force using a specific server side search strategy. Leave empty to use the default strategy. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md new file mode 100644 index 0000000000000..984f99004ebe8 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getSerializableOptions](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) + +## SearchInterceptor.getSerializableOptions() method + +Signature: + +```typescript +protected getSerializableOptions(options?: ISearchOptions): Pick; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| options | ISearchOptions | | + +Returns: + +`Pick` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 9d18309fc07be..653f052dd5a3a 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -26,6 +26,7 @@ export declare class SearchInterceptor | Method | Modifiers | Description | | --- | --- | --- | +| [getSerializableOptions(options)](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) | | | | [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | | [handleSearchError(e, options, isTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | | [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md index 623d6366d4d13..e6ba1a51a867d 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md @@ -14,7 +14,7 @@ Fetch this source and reject the returned Promise on error Signature: ```typescript -fetch(options?: ISearchOptions): Promise>; +fetch(options?: ISearchOptions): Promise>; ``` ## Parameters @@ -25,5 +25,5 @@ fetch(options?: ISearchOptions): PromiseReturns: -`Promise>` +`Promise>` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index d5641107a88aa..4369cf7c087da 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): import("rxjs").Observable>; +fetch$(options?: ISearchOptions): Observable>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): import("rxjs").ObservableReturns: -`import("rxjs").Observable>` +`Observable>` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md index f6bab8e424857..12011f8242996 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md @@ -9,5 +9,5 @@ Signature: ```typescript -aggs?: any; +aggs?: object | IAggConfigs | (() => object); ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.md index d0f53936eb56a..981d956a9e89b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.md @@ -16,7 +16,7 @@ export interface SearchSourceFields | Property | Type | Description | | --- | --- | --- | -| [aggs](./kibana-plugin-plugins-data-public.searchsourcefields.aggs.md) | any | [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) | +| [aggs](./kibana-plugin-plugins-data-public.searchsourcefields.aggs.md) | object | IAggConfigs | (() => object) | [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) | | [fields](./kibana-plugin-plugins-data-public.searchsourcefields.fields.md) | SearchFieldValue[] | Retrieve fields via the search Fields API | | [fieldsFromSource](./kibana-plugin-plugins-data-public.searchsourcefields.fieldsfromsource.md) | NameList | Retreive fields directly from \_source (legacy behavior) | | [filter](./kibana-plugin-plugins-data-public.searchsourcefields.filter.md) | Filter[] | Filter | (() => Filter[] | Filter | undefined) | [Filter](./kibana-plugin-plugins-data-public.filter.md) | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.inspector.md similarity index 52% rename from docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md rename to docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.inspector.md index 7440f5a9d26cf..ab755334643aa 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.inspector.md @@ -1,11 +1,13 @@ -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [requestResponder](./kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md) +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [inspector](./kibana-plugin-plugins-data-server.isearchoptions.inspector.md) -## ISearchOptions.requestResponder property +## ISearchOptions.inspector property + +Inspector integration options Signature: ```typescript -requestResponder?: RequestResponder; +inspector?: IInspectorInfo; ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md index 413a59be3d427..cdb5664f96cdd 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md @@ -16,10 +16,10 @@ export interface ISearchOptions | --- | --- | --- | | [abortSignal](./kibana-plugin-plugins-data-server.isearchoptions.abortsignal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | | [indexPattern](./kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md) | IndexPattern | Index pattern reference is used for better error messages | +| [inspector](./kibana-plugin-plugins-data-server.isearchoptions.inspector.md) | IInspectorInfo | Inspector integration options | | [isRestore](./kibana-plugin-plugins-data-server.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-server.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-server.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | -| [requestResponder](./kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md) | RequestResponder | | | [sessionId](./kibana-plugin-plugins-data-server.isearchoptions.sessionid.md) | string | A session ID, grouping multiple search requests into a single session. | | [strategy](./kibana-plugin-plugins-data-server.isearchoptions.strategy.md) | string | Use this option to force using a specific server side search strategy. Leave empty to use the default strategy. | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.container.children.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.container.children.md index e8f140219ed9c..a334f37ba3e7d 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.container.children.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.container.children.md @@ -7,7 +7,7 @@ Signature: ```typescript -protected readonly children: { +readonly children: { [key: string]: IEmbeddable | ErrorEmbeddable; }; ``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md new file mode 100644 index 0000000000000..1699351349bf8 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableFactory](./kibana-plugin-plugins-embeddable-public.embeddablefactory.md) > [getDescription](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md) + +## EmbeddableFactory.getDescription() method + +Returns a description about the embeddable. + +Signature: + +```typescript +getDescription(): string; +``` +Returns: + +`string` + diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md new file mode 100644 index 0000000000000..58b987e5630c4 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableFactory](./kibana-plugin-plugins-embeddable-public.embeddablefactory.md) > [getIconType](./kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md) + +## EmbeddableFactory.getIconType() method + +Returns an EUI Icon type to be displayed in a menu. + +Signature: + +```typescript +getIconType(): string; +``` +Returns: + +`string` + diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md new file mode 100644 index 0000000000000..c4dbe739ddfcb --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableFactory](./kibana-plugin-plugins-embeddable-public.embeddablefactory.md) > [grouping](./kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md) + +## EmbeddableFactory.grouping property + +Indicates the grouping this factory should appear in a sub-menu. Example, this is used for grouping options in the editors menu in Dashboard for creating new embeddables + +Signature: + +```typescript +readonly grouping?: UiActionsPresentableGrouping; +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.md index b355acd0567a8..8ee60e1f58a2b 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.md @@ -16,6 +16,7 @@ export interface EmbeddableFactoryUiActionsPresentableGrouping | Indicates the grouping this factory should appear in a sub-menu. Example, this is used for grouping options in the editors menu in Dashboard for creating new embeddables | | [isContainerType](./kibana-plugin-plugins-embeddable-public.embeddablefactory.iscontainertype.md) | boolean | True if is this factory create embeddables that are Containers. Used in the add panel to conditionally show whether these can be added to another container. It's just not supported right now, but once nested containers are officially supported we can probably get rid of this interface. | | [isEditable](./kibana-plugin-plugins-embeddable-public.embeddablefactory.iseditable.md) | () => Promise<boolean> | Returns whether the current user should be allowed to edit this type of embeddable. Most of the time this should be based off the capabilities service, hence it's async. | | [savedObjectMetaData](./kibana-plugin-plugins-embeddable-public.embeddablefactory.savedobjectmetadata.md) | SavedObjectMetaData<TSavedObjectAttributes> | | @@ -29,6 +30,8 @@ export interface EmbeddableFactoryThis will likely change in future iterations when we improve in place editing capabilities. | | [createFromSavedObject(savedObjectId, input, parent)](./kibana-plugin-plugins-embeddable-public.embeddablefactory.createfromsavedobject.md) | Creates a new embeddable instance based off the saved object id. | | [getDefaultInput(partial)](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getdefaultinput.md) | Can be used to get any default input, to be passed in to during the creation process. Default input will not be stored in a parent container, so any inherited input from a container will trump default input parameters. | +| [getDescription()](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md) | Returns a description about the embeddable. | | [getDisplayName()](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getdisplayname.md) | Returns a display name for this type of embeddable. Used in "Create new... " options in the add panel for containers. | | [getExplicitInput()](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getexplicitinput.md) | Can be used to request explicit input from the user, to be passed in to EmbeddableFactory:create. Explicit input is stored on the parent container for this embeddable. It overrides any inherited input passed down from the parent container. | +| [getIconType()](./kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md) | Returns an EUI Icon type to be displayed in a menu. | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactorydefinition.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactorydefinition.md index 6ecb88e7c017e..dd61272625160 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactorydefinition.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactorydefinition.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type EmbeddableFactoryDefinition = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations'>>; +export declare type EmbeddableFactoryDefinition = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations' | 'grouping' | 'getIconType' | 'getDescription'>>; ``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md index add4646375359..90caaa3035b34 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md @@ -14,6 +14,7 @@ export declare function openAddPanelFlyout(options: { overlays: OverlayStart; notifications: NotificationsStart; SavedObjectFinder: React.ComponentType; + showCreateNewMenu?: boolean; }): OverlayRef; ``` @@ -21,7 +22,7 @@ export declare function openAddPanelFlyout(options: { | Parameter | Type | Description | | --- | --- | --- | -| options | {
embeddable: IContainer;
getFactory: EmbeddableStart['getEmbeddableFactory'];
getAllFactories: EmbeddableStart['getEmbeddableFactories'];
overlays: OverlayStart;
notifications: NotificationsStart;
SavedObjectFinder: React.ComponentType<any>;
} | | +| options | {
embeddable: IContainer;
getFactory: EmbeddableStart['getEmbeddableFactory'];
getAllFactories: EmbeddableStart['getEmbeddableFactories'];
overlays: OverlayStart;
notifications: NotificationsStart;
SavedObjectFinder: React.ComponentType<any>;
showCreateNewMenu?: boolean;
} | | Returns: diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionspresentable.getdisplaynametooltip.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionspresentable.getdisplaynametooltip.md index 8fc859d5713e6..a35f455f7af25 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionspresentable.getdisplaynametooltip.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionspresentable.getdisplaynametooltip.md @@ -9,7 +9,7 @@ Returns tooltip text which should be displayed when user hovers this object. Sho Signature: ```typescript -getDisplayNameTooltip(context: Context): string; +getDisplayNameTooltip?(context: Context): string; ``` ## Parameters diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc new file mode 100644 index 0000000000000..4d6dcb631792e --- /dev/null +++ b/docs/management/action-types.asciidoc @@ -0,0 +1,117 @@ +[role="xpack"] +[[action-types]] +== Connectors + +Connectors provide a central place to store connection information for services and integrations with third party systems. Actions are instantiations of a connector that are linked to rules and run as background tasks on the {kib} server when rule conditions are met. {kib} provides the following types of connectors: + +[cols="2"] +|=== + +a| <> + +| Send email from your server. + +a| <> + +| Create an incident in IBM Resilient. + +a| <> + +| Index data into Elasticsearch. + +a| <> + +| Create an incident in Jira. + +a| <> + +| Send a message to a Microsoft Teams channel. + +a| <> + +| Send an event in PagerDuty. + +a| <> + +| Add a message to a Kibana log. + +a| <> + +| Create an incident in ServiceNow. + +a| <> + +| Send a message to a Slack channel or user. + +a| <> + +| Send a request to a web service. +|=== + +[NOTE] +============================================== +Some connector types are paid commercial features, while others are free. +For a comparison of the Elastic subscription levels, +see https://www.elastic.co/subscriptions[the subscription page]. +============================================== + +[float] +[[connector-management]] +=== Managing Connectors + +Rules use *Connectors* to route actions to different destinations like log files, ticketing systems, and messaging tools. While each {kib} app can offer their own types of rules, they typically share connectors. The *Connectors* tab offers a central place to view and manage all the connectors in the current space. + +For more information on connectors and the types of actions available see <>. + +[role="screenshot"] +image::images/connector-listing.png[Example connector listing in the Rules and Connectors UI] + +[float] +=== Required permissions + +Access to connectors is granted based on your privileges to alerting-enabled features. See <> for more information. + +[float] +[[connectors-list]] +=== Connector list + +The *Connectors* tab lists all connectors in the current space. The *search bar* can be used to find specific connectors by name and/or type. + +[role="screenshot"] +image::images/connector-filter-by-search.png[Filtering the connector list using the search bar] + + +The *type* dropdown also lets you filter to a subset of connector types. + +[role="screenshot"] +image::images/connector-filter-by-type.png[Filtering the connector list by types of connectors] + +You can delete individual connectors using the trash icon. Connectors can also be deleted in bulk by multi-selecting them and clicking the *Delete* button to the left of the search box. + +[role="screenshot"] +image::images/connector-delete.png[Deleting connectors individually or in bulk] + +[NOTE] +============================================================================ +You can delete a connector even if there are still actions referencing it. +When this happens the action will fail to execute, and appear as errors in the {kib} logs. +============================================================================ + +[float] +[[creating-new-connector]] +=== Creating a new connector + +New connectors can be created by clicking the *Create connector* button, which will guide you to select the type of connector and configure its properties. Refer to <> for the types of connectors available and how to configure them. Once you create a connector it will be made available to you anytime you set up an action in the current space. + +[role="screenshot"] +image::images/connector-select-type.png[Connector select type] + +[float] +[[create-connectors]] +=== Preconfigured connectors + +For out-of-the-box and standardized connectors, you can <> +before {kib} starts. + + +include::connectors/index.asciidoc[] diff --git a/docs/management/alerting/connector-management.asciidoc b/docs/management/alerting/connector-management.asciidoc deleted file mode 100644 index dd3b5209ed4a0..0000000000000 --- a/docs/management/alerting/connector-management.asciidoc +++ /dev/null @@ -1,40 +0,0 @@ -[role="xpack"] -[[connector-management]] -=== Managing Connectors - -Rules use *Connectors* to route actions to different destinations like log files, ticketing systems, and messaging tools. While each {kib} app can offer their own types of rules, they typically share connectors. The *Connectors* tab offers a central place to view and manage all the connectors in the current space. - -For more information on connectors and the types of actions available see <>. - -[role="screenshot"] -image::images/connector-listing.png[Example connector listing in the Rules and Connectors UI] - - -[float] -==== Connector list - -The *Connectors* tab lists all connectors in the current space. The *search bar* can be used to find specific connectors by name and/or type. - -[role="screenshot"] -image::images/connector-filter-by-search.png[Filtering the connector list using the search bar] - - -The *type* dropdown also lets you filter to a subset of connector types. - -[role="screenshot"] -image::images/connector-filter-by-type.png[Filtering the connector list by types of connectors] - -You can delete individual connectors using the trash icon. Connectors can also be deleted in bulk by multi-selecting them and clicking the *Delete* button to the left of the search box. - -[role="screenshot"] -image::images/connector-delete.png[Deleting connectors individually or in bulk] - -[NOTE] -============================================================================ -You can delete a connector even if there are still actions referencing it. -When this happens the action will fail to execute, and appear as errors in the {kib} logs. -============================================================================ - -==== Creating a new connector - -New connectors can be created by clicking the *Create connector* button, which will guide you to select the type of connector and configure its properties. Refer to <> for the types of connectors available and how to configure them. Once you create a connector it will be made available to you anytime you set up an action in the current space. diff --git a/docs/management/alerting/rules-and-connectors-intro.asciidoc b/docs/management/alerting/rules-and-connectors-intro.asciidoc deleted file mode 100644 index 6e23ca95e2266..0000000000000 --- a/docs/management/alerting/rules-and-connectors-intro.asciidoc +++ /dev/null @@ -1,29 +0,0 @@ -[role="xpack"] -[[managing-alerts-and-actions]] -== Rules and Connectors - - -The *Rules and Connectors* UI lets you <> in a space, and provides tools to <> so that rules can trigger actions like notification, indexing, and ticketing. - -To manage rules and connectors, open the main menu, then click *Stack Management > Alerts and Insights > Rules and Connectors*. - -[role="screenshot"] -image:management/alerting/images/rules-and-connectors-ui.png[Example rule listing in the Rules and Connectors UI] - -[NOTE] -============================================================================ -Similar to dashboards, rules and connectors reside in a <>. -The *Rules and Connectors* UI only shows rules and connectors for the current space. -============================================================================ - -[NOTE] -============================================================================ -{es} also offers alerting capabilities through Watcher, which -can be managed through the <>. See -<> for more information. -============================================================================ - -[float] -=== Required permissions - -Access to rules and connectors is granted based on your privileges to alerting-enabled features. See <> for more information. diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/management/connectors/action-types/email.asciidoc similarity index 93% rename from docs/user/alerting/action-types/email.asciidoc rename to docs/management/connectors/action-types/email.asciidoc index 58f1300d0c287..1c2f9212b4887 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/management/connectors/action-types/email.asciidoc @@ -25,7 +25,7 @@ Username:: Username for login type authentication. Password:: Password for login type authentication. [float] -[[Preconfigured-email-configuration]] +[[preconfigured-email-configuration]] ==== Preconfigured connector type [source,text] @@ -57,6 +57,19 @@ Secrets defines sensitive information for the connector type. `user`:: A string that corresponds to *Username*. Required if `hasAuth` is set to `true`. `password`:: A string that corresponds to *Password*. Should be stored in the <>. Required if `hasAuth` is set to `true`. +[float] +[[define-email-ui]] +==== Define connector in Stack Management + +Define email connector properties. + +[role="screenshot"] +image::management/connectors/images/email-connector.png[Email connector] + +Test email action parameters. + +[role="screenshot"] +image::management/connectors/images/email-params-test.png[Email params test] [float] [[email-action-configuration]] @@ -68,6 +81,7 @@ To, CC, BCC:: Each item is a list of addresses. Addresses can be specified in Subject:: The subject line of the email. Message:: The message text of the email. Markdown format is supported. +[float] [[configuring-email]] ==== Configuring email accounts for well-known services @@ -84,7 +98,7 @@ For other email servers, you can check the list of well-known services that Node [float] [[gmail]] -===== Sending email from Gmail +==== Sending email from Gmail Use the following email connector configuration to send email from the https://mail.google.com[Gmail] SMTP service: @@ -112,7 +126,7 @@ for more information. [float] [[outlook]] -===== Sending email from Outlook.com +==== Sending email from Outlook.com Use the following email connector configuration to send email from the https://www.outlook.com/[Outlook.com] SMTP service: @@ -137,7 +151,7 @@ NOTE: You must use a unique App Password if two-step verification is enabled. [float] [[amazon-ses]] -===== Sending email from Amazon SES (Simple Email Service) +==== Sending email from Amazon SES (Simple Email Service) Use the following email connector configuration to send email from the http://aws.amazon.com/ses[Amazon Simple Email Service] (SES) SMTP service: @@ -164,7 +178,7 @@ NOTE: You must use your Amazon SES SMTP credentials to send email through [float] [[exchange]] -===== Sending email from Microsoft Exchange +==== Sending email from Microsoft Exchange Use the following email connector configuration to send email from Microsoft Exchange: diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/management/connectors/action-types/index.asciidoc similarity index 92% rename from docs/user/alerting/action-types/index.asciidoc rename to docs/management/connectors/action-types/index.asciidoc index e23dcbf298fd5..d3bd3d431748c 100644 --- a/docs/user/alerting/action-types/index.asciidoc +++ b/docs/management/connectors/action-types/index.asciidoc @@ -39,6 +39,20 @@ Config defines information for the connector type. `refresh`:: A boolean that corresponds to *Refresh*. Defaults to `false`. `executionTimeField`:: A string that corresponds to *Execution time field*. +[float] +[[define-index-ui]] +==== Define connector in Stack Management + +Define Index connector properties. + +[role="screenshot"] +image::management/connectors/images/index-connector.png[Index connector] + +Test Index action parameters. + +[role="screenshot"] +image::management/connectors/images/index-params-test.png[Index params test] + [float] [[index-action-configuration]] ==== Action configuration diff --git a/docs/user/alerting/action-types/jira.asciidoc b/docs/management/connectors/action-types/jira.asciidoc similarity index 86% rename from docs/user/alerting/action-types/jira.asciidoc rename to docs/management/connectors/action-types/jira.asciidoc index 7b4dc69bb639a..a5e629887d5c6 100644 --- a/docs/user/alerting/action-types/jira.asciidoc +++ b/docs/management/connectors/action-types/jira.asciidoc @@ -46,6 +46,20 @@ Secrets defines sensitive information for the connector type. `email`:: A string that corresponds to *Email*. `apiToken`:: A string that corresponds to *API Token*. Should be stored in the <>. +[float] +[[define-jira-ui]] +==== Define connector in Stack Management + +Define Jira connector properties. + +[role="screenshot"] +image::management/connectors/images/jira-connector.png[Jira connector] + +Test Jira action parameters. + +[role="screenshot"] +image::management/connectors/images/jira-params-test.png[Jira params test] + [float] [[jira-action-configuration]] ==== Action configuration @@ -60,6 +74,7 @@ Description:: The details about the incident. Parent:: The ID or key of the parent issue. Only for `Subtask` issue types. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. +[float] [[configuring-jira]] ==== Configure Jira diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/management/connectors/action-types/pagerduty.asciidoc similarity index 93% rename from docs/user/alerting/action-types/pagerduty.asciidoc rename to docs/management/connectors/action-types/pagerduty.asciidoc index c32e6c8a6635f..25cba05010548 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/management/connectors/action-types/pagerduty.asciidoc @@ -40,6 +40,20 @@ Secrets defines sensitive information for the connector type. `routingKey`:: A string that corresponds to *Integration Key*. +[float] +[[define-pagerduty-ui]] +==== Define connector in Stack Management + +Define PagerDuty connector properties. + +[role="screenshot"] +image::management/connectors/images/pagerduty-connector.png[PagerDuty connector] + +Test PagerDuty action parameters. + +[role="screenshot"] +image::management/connectors/images/pagerduty-params-test.png[PagerDuty params test] + [float] [[pagerduty-action-configuration]] ==== Action configuration @@ -70,14 +84,14 @@ By integrating PagerDuty with rules, you can: [float] [[pagerduty-support]] -===== Support +==== Support If you need help with this integration, get in touch with the {kib} team by visiting https://support.elastic.co[support.elastic.co] or by using the *Ask Elastic* option in the {kib} Help menu. You can also select the {kib} category at https://discuss.elastic.co/[discuss.elastic.co]. [float] [[pagerduty-integration-walkthrough]] -===== Integration with PagerDuty walkthrough +==== Integration with PagerDuty walkthrough [[pagerduty-in-pagerduty]] *In PagerDuty* @@ -101,7 +115,7 @@ and select *Elastic Alerts* from the *Integration Type* menu. You will be redirected to the *Integrations* tab for your service. An Integration Key is generated on this screen. + [role="screenshot"] -image::user/alerting/images/pagerduty-integration.png[PagerDuty Integrations tab] +image::images/pagerduty-integration.png[PagerDuty Integrations tab] . Save this key, as you will use it when you configure the integration with Elastic in the next section. diff --git a/docs/user/alerting/action-types/resilient.asciidoc b/docs/management/connectors/action-types/resilient.asciidoc similarity index 85% rename from docs/user/alerting/action-types/resilient.asciidoc rename to docs/management/connectors/action-types/resilient.asciidoc index 862af0e526337..454ae145bbc57 100644 --- a/docs/user/alerting/action-types/resilient.asciidoc +++ b/docs/management/connectors/action-types/resilient.asciidoc @@ -46,6 +46,20 @@ Secrets defines sensitive information for the connector type. `apiKeyId`:: A string that corresponds to *API key ID*. `apiKeySecret`:: A string that corresponds to *API Key secret*. Should be stored in the <>. +[float] +[[define-resilient-ui]] +==== Define connector in Stack Management + +Define IBM Resilient connector properties. + +[role="screenshot"] +image::management/connectors/images/resilient-connector.png[IBM Resilient connector] + +Test IBM Resilient action parameters. + +[role="screenshot"] +image::management/connectors/images/resilient-params-test.png[IBM Resilient params test] + [float] [[resilient-action-configuration]] ==== Action configuration @@ -58,6 +72,7 @@ Name:: A name for the issue, used for searching the contents of the knowledge ba Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. +[float] [[configuring-resilient]] ==== Configure IBM Resilient diff --git a/docs/user/alerting/action-types/server-log.asciidoc b/docs/management/connectors/action-types/server-log.asciidoc similarity index 72% rename from docs/user/alerting/action-types/server-log.asciidoc rename to docs/management/connectors/action-types/server-log.asciidoc index e497ed3a4467a..0810724d39ead 100644 --- a/docs/user/alerting/action-types/server-log.asciidoc +++ b/docs/management/connectors/action-types/server-log.asciidoc @@ -26,6 +26,20 @@ Name:: The name of the connector. The name is used to identify a connector actionTypeId: .server-log -- +[float] +[[define-serverlog-ui]] +==== Define connector in Stack Management + +Define Server log connector properties. + +[role="screenshot"] +image::management/connectors/images/serverlog-connector.png[Server log connector] + +Test Server log action parameters. + +[role="screenshot"] +image::management/connectors/images/serverlog-params-test.png[Server log params test] + [float] [[server-log-action-configuration]] ==== Action configuration diff --git a/docs/user/alerting/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc similarity index 86% rename from docs/user/alerting/action-types/servicenow.asciidoc rename to docs/management/connectors/action-types/servicenow.asciidoc index 35d50f1bfeb72..24892c62e804b 100644 --- a/docs/user/alerting/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -43,6 +43,20 @@ Secrets defines sensitive information for the connector type. `username`:: A string that corresponds to *Username*. `password`:: A string that corresponds to *Password*. Should be stored in the <>. +[float] +[[define-servicenow-ui]] +==== Define connector in Stack Management + +Define ServiceNow connector properties. + +[role="screenshot"] +image::management/connectors/images/servicenow-connector.png[ServiceNow connector] + +Test ServiceNow action parameters. + +[role="screenshot"] +image::management/connectors/images/servicenow-params-test.png[ServiceNow params test] + [float] [[servicenow-action-configuration]] ==== Action configuration @@ -56,6 +70,7 @@ Short description:: A short description for the incident, used for searching Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. +[float] [[configuring-servicenow]] ==== Configure ServiceNow diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/management/connectors/action-types/slack.asciidoc similarity index 86% rename from docs/user/alerting/action-types/slack.asciidoc rename to docs/management/connectors/action-types/slack.asciidoc index 3ef369e2a4e4a..da0bf321f9ade 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/management/connectors/action-types/slack.asciidoc @@ -33,6 +33,20 @@ Secrets defines sensitive information for the connector type. `webhookUrl`:: A string that corresponds to *Webhook URL*. +[float] +[[define-slack-ui]] +==== Define connector in Stack Management + +Define Slack connector properties. + +[role="screenshot"] +image::management/connectors/images/slack-connector.png[Slack connector] + +Test Slack action parameters. + +[role="screenshot"] +image::management/connectors/images/slack-params-test.png[Slack params test] + [float] [[slack-action-configuration]] ==== Action configuration @@ -41,6 +55,7 @@ Slack actions have the following properties. Message:: The message text, converted to the `text` field in the Webhook JSON payload. Currently only the text field is supported. Markdown, images, and other advanced formatting are not yet supported. +[float] [[configuring-slack]] ==== Configure a Slack account diff --git a/docs/user/alerting/action-types/teams.asciidoc b/docs/management/connectors/action-types/teams.asciidoc similarity index 88% rename from docs/user/alerting/action-types/teams.asciidoc rename to docs/management/connectors/action-types/teams.asciidoc index 1a0e52141ee9c..ba723a6f33c86 100644 --- a/docs/user/alerting/action-types/teams.asciidoc +++ b/docs/management/connectors/action-types/teams.asciidoc @@ -33,6 +33,20 @@ Secrets defines sensitive information for the connector type. `webhookUrl`:: A string that corresponds to *Webhook URL*. +[float] +[[define-teams-ui]] +==== Define connector in Stack Management + +Define Teams connector properties. + +[role="screenshot"] +image::management/connectors/images/teams-connector.png[Teams connector] + +Test Teams action parameters. + +[role="screenshot"] +image::management/connectors/images/teams-params-test.png[Teams params test] + [float] [[teams-action-configuration]] ==== Action configuration @@ -41,6 +55,7 @@ Microsoft Teams actions have the following properties. Message:: The message text, converted to the `text` field in the Webhook JSON payload. Currently only the text field is supported. Markdown, images, and other advanced formatting are not yet supported. +[float] [[configuring-teams]] ==== Configure a Microsoft Teams account diff --git a/docs/user/alerting/action-types/webhook.asciidoc b/docs/management/connectors/action-types/webhook.asciidoc similarity index 88% rename from docs/user/alerting/action-types/webhook.asciidoc rename to docs/management/connectors/action-types/webhook.asciidoc index 01ddfee288fc8..a2024b9457a1c 100644 --- a/docs/user/alerting/action-types/webhook.asciidoc +++ b/docs/management/connectors/action-types/webhook.asciidoc @@ -52,6 +52,20 @@ Secrets defines sensitive information for the connector type. `user`:: A string that corresponds to *User*. Required if `hasAuth` is set to `true`. `password`:: A string that corresponds to *Password*. Should be stored in the <>. Required if `hasAuth` is set to `true`. +[float] +[[define-webhook-ui]] +==== Define connector in Stack Management + +Define Webhook connector properties. + +[role="screenshot"] +image::management/connectors/images/webhook-connector.png[Webhook connector] + +Test Webhook action parameters. + +[role="screenshot"] +image::management/connectors/images/webhook-params-test.png[Webhook params test] + [float] [[webhook-action-configuration]] ==== Action configuration diff --git a/docs/management/alerting/images/connector-action-count.png b/docs/management/connectors/images/connector-action-count.png similarity index 100% rename from docs/management/alerting/images/connector-action-count.png rename to docs/management/connectors/images/connector-action-count.png diff --git a/docs/management/alerting/images/connector-delete.png b/docs/management/connectors/images/connector-delete.png similarity index 100% rename from docs/management/alerting/images/connector-delete.png rename to docs/management/connectors/images/connector-delete.png diff --git a/docs/management/alerting/images/connector-filter-by-search.png b/docs/management/connectors/images/connector-filter-by-search.png similarity index 100% rename from docs/management/alerting/images/connector-filter-by-search.png rename to docs/management/connectors/images/connector-filter-by-search.png diff --git a/docs/management/alerting/images/connector-filter-by-type.png b/docs/management/connectors/images/connector-filter-by-type.png similarity index 100% rename from docs/management/alerting/images/connector-filter-by-type.png rename to docs/management/connectors/images/connector-filter-by-type.png diff --git a/docs/management/alerting/images/connector-listing.png b/docs/management/connectors/images/connector-listing.png similarity index 100% rename from docs/management/alerting/images/connector-listing.png rename to docs/management/connectors/images/connector-listing.png diff --git a/docs/management/connectors/images/connector-select-type.png b/docs/management/connectors/images/connector-select-type.png new file mode 100644 index 0000000000000..ef5825b149311 Binary files /dev/null and b/docs/management/connectors/images/connector-select-type.png differ diff --git a/docs/management/connectors/images/email-connector.png b/docs/management/connectors/images/email-connector.png new file mode 100644 index 0000000000000..b837fa545a4d1 Binary files /dev/null and b/docs/management/connectors/images/email-connector.png differ diff --git a/docs/management/connectors/images/email-params-test.png b/docs/management/connectors/images/email-params-test.png new file mode 100644 index 0000000000000..3745bcd3235e9 Binary files /dev/null and b/docs/management/connectors/images/email-params-test.png differ diff --git a/docs/management/connectors/images/index-connector.png b/docs/management/connectors/images/index-connector.png new file mode 100644 index 0000000000000..b3a81a7c0e761 Binary files /dev/null and b/docs/management/connectors/images/index-connector.png differ diff --git a/docs/management/connectors/images/index-params-test.png b/docs/management/connectors/images/index-params-test.png new file mode 100644 index 0000000000000..6f4f83bd4297c Binary files /dev/null and b/docs/management/connectors/images/index-params-test.png differ diff --git a/docs/management/connectors/images/jira-connector.png b/docs/management/connectors/images/jira-connector.png new file mode 100644 index 0000000000000..5ff5ebf83afc7 Binary files /dev/null and b/docs/management/connectors/images/jira-connector.png differ diff --git a/docs/management/connectors/images/jira-params-test.png b/docs/management/connectors/images/jira-params-test.png new file mode 100644 index 0000000000000..78d51e823fb61 Binary files /dev/null and b/docs/management/connectors/images/jira-params-test.png differ diff --git a/docs/management/connectors/images/pagerduty-connector.png b/docs/management/connectors/images/pagerduty-connector.png new file mode 100644 index 0000000000000..2e5d240f42c11 Binary files /dev/null and b/docs/management/connectors/images/pagerduty-connector.png differ diff --git a/docs/user/alerting/images/pagerduty-integration.png b/docs/management/connectors/images/pagerduty-integration.png similarity index 100% rename from docs/user/alerting/images/pagerduty-integration.png rename to docs/management/connectors/images/pagerduty-integration.png diff --git a/docs/management/connectors/images/pagerduty-params-test.png b/docs/management/connectors/images/pagerduty-params-test.png new file mode 100644 index 0000000000000..3fb4a9bb5dc82 Binary files /dev/null and b/docs/management/connectors/images/pagerduty-params-test.png differ diff --git a/docs/user/alerting/images/pre-configured-connectors-managing.png b/docs/management/connectors/images/pre-configured-connectors-managing.png similarity index 100% rename from docs/user/alerting/images/pre-configured-connectors-managing.png rename to docs/management/connectors/images/pre-configured-connectors-managing.png diff --git a/docs/user/alerting/images/pre-configured-connectors-view-screen.png b/docs/management/connectors/images/pre-configured-connectors-view-screen.png similarity index 100% rename from docs/user/alerting/images/pre-configured-connectors-view-screen.png rename to docs/management/connectors/images/pre-configured-connectors-view-screen.png diff --git a/docs/management/connectors/images/resilient-connector.png b/docs/management/connectors/images/resilient-connector.png new file mode 100644 index 0000000000000..b7d216d150f8c Binary files /dev/null and b/docs/management/connectors/images/resilient-connector.png differ diff --git a/docs/management/connectors/images/resilient-params-test.png b/docs/management/connectors/images/resilient-params-test.png new file mode 100644 index 0000000000000..865d5b517aea2 Binary files /dev/null and b/docs/management/connectors/images/resilient-params-test.png differ diff --git a/docs/management/connectors/images/serverlog-connector.png b/docs/management/connectors/images/serverlog-connector.png new file mode 100644 index 0000000000000..983bb6afadd65 Binary files /dev/null and b/docs/management/connectors/images/serverlog-connector.png differ diff --git a/docs/management/connectors/images/serverlog-params-test.png b/docs/management/connectors/images/serverlog-params-test.png new file mode 100644 index 0000000000000..762721c7ead45 Binary files /dev/null and b/docs/management/connectors/images/serverlog-params-test.png differ diff --git a/docs/management/connectors/images/servicenow-connector.png b/docs/management/connectors/images/servicenow-connector.png new file mode 100644 index 0000000000000..9891a80ee758f Binary files /dev/null and b/docs/management/connectors/images/servicenow-connector.png differ diff --git a/docs/management/connectors/images/servicenow-params-test.png b/docs/management/connectors/images/servicenow-params-test.png new file mode 100644 index 0000000000000..79f1580c873d2 Binary files /dev/null and b/docs/management/connectors/images/servicenow-params-test.png differ diff --git a/docs/management/connectors/images/servicenow-sir-connector.png b/docs/management/connectors/images/servicenow-sir-connector.png new file mode 100644 index 0000000000000..fbb137bd4f7d9 Binary files /dev/null and b/docs/management/connectors/images/servicenow-sir-connector.png differ diff --git a/docs/management/connectors/images/servicenow-sir-params-test.png b/docs/management/connectors/images/servicenow-sir-params-test.png new file mode 100644 index 0000000000000..16ea83c60b3c3 Binary files /dev/null and b/docs/management/connectors/images/servicenow-sir-params-test.png differ diff --git a/docs/user/alerting/images/slack-add-webhook-integration.png b/docs/management/connectors/images/slack-add-webhook-integration.png similarity index 100% rename from docs/user/alerting/images/slack-add-webhook-integration.png rename to docs/management/connectors/images/slack-add-webhook-integration.png diff --git a/docs/management/connectors/images/slack-connector.png b/docs/management/connectors/images/slack-connector.png new file mode 100644 index 0000000000000..7342d962d2a2b Binary files /dev/null and b/docs/management/connectors/images/slack-connector.png differ diff --git a/docs/user/alerting/images/slack-copy-webhook-url.png b/docs/management/connectors/images/slack-copy-webhook-url.png similarity index 100% rename from docs/user/alerting/images/slack-copy-webhook-url.png rename to docs/management/connectors/images/slack-copy-webhook-url.png diff --git a/docs/management/connectors/images/slack-params-test.png b/docs/management/connectors/images/slack-params-test.png new file mode 100644 index 0000000000000..603f66301af12 Binary files /dev/null and b/docs/management/connectors/images/slack-params-test.png differ diff --git a/docs/user/alerting/images/teams-add-webhook-integration.png b/docs/management/connectors/images/teams-add-webhook-integration.png similarity index 100% rename from docs/user/alerting/images/teams-add-webhook-integration.png rename to docs/management/connectors/images/teams-add-webhook-integration.png diff --git a/docs/management/connectors/images/teams-connector.png b/docs/management/connectors/images/teams-connector.png new file mode 100644 index 0000000000000..4b9112db28474 Binary files /dev/null and b/docs/management/connectors/images/teams-connector.png differ diff --git a/docs/user/alerting/images/teams-copy-webhook-url.png b/docs/management/connectors/images/teams-copy-webhook-url.png similarity index 100% rename from docs/user/alerting/images/teams-copy-webhook-url.png rename to docs/management/connectors/images/teams-copy-webhook-url.png diff --git a/docs/management/connectors/images/teams-params-test.png b/docs/management/connectors/images/teams-params-test.png new file mode 100644 index 0000000000000..01efb84ae60d4 Binary files /dev/null and b/docs/management/connectors/images/teams-params-test.png differ diff --git a/docs/management/connectors/images/webhook-connector.png b/docs/management/connectors/images/webhook-connector.png new file mode 100644 index 0000000000000..6046572734afd Binary files /dev/null and b/docs/management/connectors/images/webhook-connector.png differ diff --git a/docs/management/connectors/images/webhook-params-test.png b/docs/management/connectors/images/webhook-params-test.png new file mode 100644 index 0000000000000..7b7007a72a0b6 Binary files /dev/null and b/docs/management/connectors/images/webhook-params-test.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc new file mode 100644 index 0000000000000..ea4fa46d3e808 --- /dev/null +++ b/docs/management/connectors/index.asciidoc @@ -0,0 +1,11 @@ +include::action-types/email.asciidoc[] +include::action-types/resilient.asciidoc[] +include::action-types/index.asciidoc[] +include::action-types/jira.asciidoc[] +include::action-types/teams.asciidoc[] +include::action-types/pagerduty.asciidoc[] +include::action-types/server-log.asciidoc[] +include::action-types/servicenow.asciidoc[] +include::action-types/slack.asciidoc[] +include::action-types/webhook.asciidoc[] +include::pre-configured-connectors.asciidoc[] diff --git a/docs/user/alerting/action-types/pre-configured-connectors.asciidoc b/docs/management/connectors/pre-configured-connectors.asciidoc similarity index 99% rename from docs/user/alerting/action-types/pre-configured-connectors.asciidoc rename to docs/management/connectors/pre-configured-connectors.asciidoc index 557404f24288a..4d304cdd6c5a2 100644 --- a/docs/user/alerting/action-types/pre-configured-connectors.asciidoc +++ b/docs/management/connectors/pre-configured-connectors.asciidoc @@ -1,6 +1,5 @@ [role="xpack"] [[pre-configured-connectors]] - === Preconfigured connectors You can preconfigure a connector to have all the information it needs prior to startup by adding it to the `kibana.yml` file. diff --git a/docs/management/alerting/images/rules-and-connectors-ui.png b/docs/management/images/rules-and-connectors-ui.png similarity index 100% rename from docs/management/alerting/images/rules-and-connectors-ui.png rename to docs/management/images/rules-and-connectors-ui.png diff --git a/docs/maps/trouble-shooting.asciidoc b/docs/maps/trouble-shooting.asciidoc index 11aa636e0d852..0e1ed0b9e1bec 100644 --- a/docs/maps/trouble-shooting.asciidoc +++ b/docs/maps/trouble-shooting.asciidoc @@ -32,7 +32,7 @@ image::maps/images/inspector.png[] ** Ensure your geospatial field is searchable and aggregatable. ** If your geospatial field type does not match your Elasticsearch mapping, click the *Refresh* button to refresh the field list from Elasticsearch. * Index patterns with thousands of fields can exceed the default maximum payload size. -Increase <> for large index patterns. +Increase <> for large index patterns. [float] ==== Features are not displayed diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 2d330445d9ced..9c054fbc00222 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -37,12 +37,10 @@ See the {fleet-guide}/index.html[{fleet}] docs for more information. [cols="2*<"] |=== -| `xpack.fleet.agents.kibana.host` - | The hostname used by {agent} for accessing {kib}. +| `xpack.fleet.agents.fleet_server.hosts` + | Hostnames used by {agent} for accessing {fleet-server}. | `xpack.fleet.agents.elasticsearch.host` | The hostname used by {agent} for accessing {es}. -| `xpack.fleet.agents.tlsCheckDisabled` - | Set to `true` to allow {fleet} to run on a {kib} instance without TLS enabled. |=== [NOTE] diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 90e813afad6f4..4011470013a75 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -25,12 +25,12 @@ which may cause a delay before pages start being served. Set to `false` to disable Console. *Default: `true`* | `cpu.cgroup.path.override:` - | deprecated:[7.10.0,"This setting will no longer be supported as of 8.0."] + | deprecated:[7.10.0,"In 8.0 and later, this setting will no longer be supported."] This setting has been renamed to <>. | `cpuacct.cgroup.path.override:` - | deprecated:[7.10.0,"This setting will no longer be supported as of 8.0."] + | deprecated:[7.10.0,"In 8.0 and later, this setting will no longer be supported."] This setting has been renamed to <>. @@ -473,7 +473,7 @@ confident your server can hold this many objects in memory. | The maximum byte size of a saved objects import that the {kib} server will accept. This setting exists to prevent the {kib} server from runnning out of memory when handling a large import payload. Note that this setting overrides the more general -<> for saved object imports only. +<> for saved object imports only. *Default: `26214400`* |[[server-basePath]] `server.basePath:` @@ -504,10 +504,62 @@ deprecation warning at startup. This setting cannot end in a slash (`/`). proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request `Referer` header. This setting may not be used when <> is set to `false`. *Default: `none`* + +a| [[server-securityResponseHeaders-strictTransportSecurity]] +---- +server.securityResponseHeaders: + strictTransportSecurity: +---- +| Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security[`Strict-Transport-Security`] +header 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` + +a| [[server-securityResponseHeaders-xContentTypeOptions]] +---- +server.securityResponseHeaders: + xContentTypeOptions: +---- +| Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options[`X-Content-Type-Options`] header is +used in all responses to the client from the {kib} server, and specifies what value is used. Allowed values are `nosniff` or `null`. To +disable, set to `null`. *Default:* `"nosniff"` + +a| [[server-securityResponseHeaders-referrerPolicy]] +---- +server.securityResponseHeaders: + referrerPolicy: +---- +| Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy[`Referrer-Policy`] header is used in all +responses to the client from the {kib} server, and specifies what value is used. Allowed values are `no-referrer`, +`no-referrer-when-downgrade`, `origin`, `origin-when-cross-origin`, `same-origin`, `strict-origin`, `strict-origin-when-cross-origin`, +`unsafe-url`, or `null`. To disable, set to `null`. *Default:* `"no-referrer-when-downgrade"` + +a| [[server-securityResponseHeaders-permissionsPolicy]] +---- +server.securityResponseHeaders: + permissionsPolicy: +---- +| experimental[] Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy[`Permissions-Policy`] header +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` + +a| [[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 +{kib} in other webpages using iframes. When set to `true`, secure headers are used to disable embedding, which adds the `frame-ancestors: +'self'` directive to the `Content-Security-Policy` response header (if you are using the default CSP rules), and adds the `X-Frame-Options: +SAMEORIGIN` response header. *Default:* `false` + | `server.customResponseHeaders:` {ess-icon} | Header names and values to send on all responses to the client from the {kib} server. *Default: `{}`* +|[[server-shutdownTimeout]] `server.shutdownTimeout:` +| Sets the grace period for {kib} to attempt to resolve any ongoing HTTP requests after receiving a `SIGTERM`/`SIGINT` signal, and before shutting down. Any new HTTP requests received during this period are rejected with a `503` response. *Default: `30s`* + |[[server-host]] `server.host:` | This setting specifies the host of the back end server. To allow remote users to connect, set the value to the IP address or DNS name of the {kib} server. *Default: `"localhost"`* @@ -517,6 +569,10 @@ back end server. To allow remote users to connect, set the value to the IP addre the <> counter. *Default: `"120000"`* |[[server-maxPayloadBytes]] `server.maxPayloadBytes:` + | deprecated:[7.13.0,"In 8.0 and later, this setting will no longer be supported."] + This setting has been renamed to <>. + +|[[server-maxPayload]] `server.maxPayload:` | The maximum payload size in bytes for incoming server requests. *Default: `1048576`* diff --git a/docs/user/alerting/action-types.asciidoc b/docs/user/alerting/action-types.asciidoc deleted file mode 100644 index b648f5a9ccde5..0000000000000 --- a/docs/user/alerting/action-types.asciidoc +++ /dev/null @@ -1,75 +0,0 @@ -[role="xpack"] -[[action-types]] -== Connectors and actions - -Connectors provide a central place to store connection information for services and integrations with third party systems. Actions are instantiations of a connector that are linked to rules and run as background tasks on the {kib} server when rule conditions are met. {kib} provides the following types of connectors: - -[cols="2"] -|=== - -a| <> - -| Send email from your server. - -a| <> - -| Create an incident in IBM Resilient. - -a| <> - -| Index data into Elasticsearch. - -a| <> - -| Create an incident in Jira. - -a| <> - -| Send a message to a Microsoft Teams channel. - -a| <> - -| Send an event in PagerDuty. - -a| <> - -| Add a message to a Kibana log. - -a| <> - -| Create an incident in ServiceNow. - -a| <> - -| Send a message to a Slack channel or user. - -a| <> - -| Send a request to a web service. -|=== - -[NOTE] -============================================== -Some connector types are paid commercial features, while others are free. -For a comparison of the Elastic subscription levels, -see https://www.elastic.co/subscriptions[the subscription page]. -============================================== - -[float] -[[create-connectors]] -=== Preconfigured connectors - -For out-of-the-box and standardized connectors, you can <> -before {kib} starts. - -include::action-types/email.asciidoc[] -include::action-types/resilient.asciidoc[] -include::action-types/index.asciidoc[] -include::action-types/jira.asciidoc[] -include::action-types/teams.asciidoc[] -include::action-types/pagerduty.asciidoc[] -include::action-types/server-log.asciidoc[] -include::action-types/servicenow.asciidoc[] -include::action-types/slack.asciidoc[] -include::action-types/webhook.asciidoc[] -include::action-types/pre-configured-connectors.asciidoc[] diff --git a/docs/user/alerting/alerting-getting-started.asciidoc b/docs/user/alerting/alerting-getting-started.asciidoc index 2c8985075398e..bb11d2a0be423 100644 --- a/docs/user/alerting/alerting-getting-started.asciidoc +++ b/docs/user/alerting/alerting-getting-started.asciidoc @@ -5,7 +5,7 @@ -- -Alerting allows you to define *rules* to detect complex conditions within different {kib} apps and trigger actions when those conditions are met. Alerting is integrated with {observability-guide}/create-alerts.html[*Observability*], {security-guide}/prebuilt-rules.html[*Security*], <> and {ml-docs}/ml-configuring-alerts.html[*{ml-app}*], can be centrally managed from the <> UI, and provides a set of built-in <> and <> (known as stack rules) for you to use. +Alerting allows you to define *rules* to detect complex conditions within different {kib} apps and trigger actions when those conditions are met. Alerting is integrated with {observability-guide}/create-alerts.html[*Observability*], {security-guide}/prebuilt-rules.html[*Security*], <> and {ml-docs}/ml-configuring-alerts.html[*{ml-app}*], can be centrally managed from the <> UI, and provides a set of built-in <> and <> (known as stack rules) for you to use. image::images/alerting-overview.png[Rules and Connectors UI] @@ -47,7 +47,7 @@ to control the details of the conditions to detect. For example, an <> lets you specify the index to query, an aggregation field, and a time window, but the details of the underlying {es} query are hidden. -See <> for the types of rules provided by {kib} and how they express their conditions. +See <> and <> for the types of rules provided by {kib} and how they express their conditions. [float] [[alerting-concepts-scheduling]] diff --git a/docs/user/alerting/domain-specific-rules.asciidoc b/docs/user/alerting/domain-specific-rules.asciidoc new file mode 100644 index 0000000000000..f509f9e528823 --- /dev/null +++ b/docs/user/alerting/domain-specific-rules.asciidoc @@ -0,0 +1,20 @@ +[role="xpack"] +[[domain-specific-rules]] +== Domain-specific rules + +For domain-specific rules, refer to the documentation for that app. +{kib} supports these rules: + +* {observability-guide}/create-alerts.html[Observability rules] +* {security-guide}/prebuilt-rules.html[Security rules] +* <> +* {ml-docs}/ml-configuring-alerts.html[{ml-cap} rules] beta:[] + +[NOTE] +============================================== +Some rule types are subscription features, while others are free features. +For a comparison of the Elastic subscription levels, +see {subscriptions}[the subscription page]. +============================================== + +include::map-rules/geo-rule-types.asciidoc[] diff --git a/docs/management/alerting/images/bulk-mute-disable.png b/docs/user/alerting/images/bulk-mute-disable.png similarity index 100% rename from docs/management/alerting/images/bulk-mute-disable.png rename to docs/user/alerting/images/bulk-mute-disable.png diff --git a/docs/management/alerting/images/follower_indices.png b/docs/user/alerting/images/follower_indices.png similarity index 100% rename from docs/management/alerting/images/follower_indices.png rename to docs/user/alerting/images/follower_indices.png diff --git a/docs/management/alerting/images/individual-mute-disable.png b/docs/user/alerting/images/individual-mute-disable.png similarity index 100% rename from docs/management/alerting/images/individual-mute-disable.png rename to docs/user/alerting/images/individual-mute-disable.png diff --git a/docs/management/alerting/images/rule-details-alert-muting.png b/docs/user/alerting/images/rule-details-alert-muting.png similarity index 100% rename from docs/management/alerting/images/rule-details-alert-muting.png rename to docs/user/alerting/images/rule-details-alert-muting.png diff --git a/docs/management/alerting/images/rule-details-alerts-active.png b/docs/user/alerting/images/rule-details-alerts-active.png similarity index 100% rename from docs/management/alerting/images/rule-details-alerts-active.png rename to docs/user/alerting/images/rule-details-alerts-active.png diff --git a/docs/management/alerting/images/rule-details-alerts-inactive.png b/docs/user/alerting/images/rule-details-alerts-inactive.png similarity index 100% rename from docs/management/alerting/images/rule-details-alerts-inactive.png rename to docs/user/alerting/images/rule-details-alerts-inactive.png diff --git a/docs/management/alerting/images/rule-details-disabling.png b/docs/user/alerting/images/rule-details-disabling.png similarity index 100% rename from docs/management/alerting/images/rule-details-disabling.png rename to docs/user/alerting/images/rule-details-disabling.png diff --git a/docs/management/alerting/images/rule-details-muting.png b/docs/user/alerting/images/rule-details-muting.png similarity index 100% rename from docs/management/alerting/images/rule-details-muting.png rename to docs/user/alerting/images/rule-details-muting.png diff --git a/docs/user/alerting/images/rules-and-connectors-ui.png b/docs/user/alerting/images/rules-and-connectors-ui.png new file mode 100644 index 0000000000000..3a464fa7cf375 Binary files /dev/null and b/docs/user/alerting/images/rules-and-connectors-ui.png differ diff --git a/docs/management/alerting/images/rules-filter-by-action-type.png b/docs/user/alerting/images/rules-filter-by-action-type.png similarity index 100% rename from docs/management/alerting/images/rules-filter-by-action-type.png rename to docs/user/alerting/images/rules-filter-by-action-type.png diff --git a/docs/management/alerting/images/rules-filter-by-search.png b/docs/user/alerting/images/rules-filter-by-search.png similarity index 100% rename from docs/management/alerting/images/rules-filter-by-search.png rename to docs/user/alerting/images/rules-filter-by-search.png diff --git a/docs/management/alerting/images/rules-filter-by-type.png b/docs/user/alerting/images/rules-filter-by-type.png similarity index 100% rename from docs/management/alerting/images/rules-filter-by-type.png rename to docs/user/alerting/images/rules-filter-by-type.png diff --git a/docs/user/alerting/index.asciidoc b/docs/user/alerting/index.asciidoc index a29ca1c4ca012..f8a5aacce8f0e 100644 --- a/docs/user/alerting/index.asciidoc +++ b/docs/user/alerting/index.asciidoc @@ -1,5 +1,7 @@ include::alerting-getting-started.asciidoc[] include::defining-rules.asciidoc[] -include::action-types.asciidoc[] -include::rule-types.asciidoc[] +include::rule-management.asciidoc[] +include::rule-details.asciidoc[] +include::stack-rules.asciidoc[] +include::domain-specific-rules.asciidoc[] include::alerting-troubleshooting.asciidoc[] diff --git a/docs/management/alerting/rule-details.asciidoc b/docs/user/alerting/rule-details.asciidoc similarity index 99% rename from docs/management/alerting/rule-details.asciidoc rename to docs/user/alerting/rule-details.asciidoc index a893db280c7f7..6e743595e5c33 100644 --- a/docs/management/alerting/rule-details.asciidoc +++ b/docs/user/alerting/rule-details.asciidoc @@ -1,6 +1,6 @@ [role="xpack"] [[rule-details]] -=== Rule details +== Rule details The *Rule details* page tells you about the state of the rule and provides granular control over the actions it is taking. diff --git a/docs/management/alerting/rule-management.asciidoc b/docs/user/alerting/rule-management.asciidoc similarity index 74% rename from docs/management/alerting/rule-management.asciidoc rename to docs/user/alerting/rule-management.asciidoc index b43dc9eb635e9..b908bd03b0992 100644 --- a/docs/management/alerting/rule-management.asciidoc +++ b/docs/user/alerting/rule-management.asciidoc @@ -1,6 +1,6 @@ [role="xpack"] [[alert-management]] -=== Managing Rules +== Managing rules The *Rules* tab provides a cross-app view of alerting. Different {kib} apps like {observability-guide}/create-alerts.html[*Observability*], {security-guide}/prebuilt-rules.html[*Security*], <> and <> can offer their own rules. The *Rules* tab provides a central place to: @@ -10,12 +10,12 @@ The *Rules* tab provides a cross-app view of alerting. Different {kib} apps like * Drill-down to <> [role="screenshot"] -image:management/alerting/images/rules-and-connectors-ui.png[Example rule listing in the Rules and Connectors UI] +image:images/rules-and-connectors-ui.png[Example rule listing in the Rules and Connectors UI] For more information on alerting concepts and the types of rules and connectors available, see <>. [float] -==== Finding rules +=== Finding rules The *Rules* tab lists all rules in the current space, including summary information about their execution frequency, tags, and type. @@ -36,23 +36,28 @@ image::images/rules-filter-by-action-type.png[Filtering the rule list by type of [float] [[create-edit-rules]] -==== Creating and editing rules +=== Creating and editing rules -Many rules must be created within the context of a {kib} app like <>, <>, or <>, but others are generic. Generic rule types can be created in the *Rules* management UI by clicking the *Create* button. This will launch a flyout that guides you through selecting a rule type and configuring its properties. Refer to <> for details on what types of rules are available and how to configure them. +Many rules must be created within the context of a {kib} app like <>, <>, or <>, but others are generic. Generic rule types can be created in the *Rules* management UI by clicking the *Create* button. This will launch a flyout that guides you through selecting a rule type and configuring its properties. Refer to <> for details on what types of rules are available and how to configure them. After a rule is created, you can re-open the flyout and change a rule's properties by clicking the *Edit* button shown on each row of the rule listing. [float] [[controlling-rules]] -==== Controlling rules +=== Controlling rules The rule listing allows you to quickly mute/unmute, disable/enable, and delete individual rules by clicking the action button. [role="screenshot"] -image:management/alerting/images/individual-mute-disable.png[The actions button allows an individual rule to be muted, disabled, or deleted] +image:images/individual-mute-disable.png[The actions button allows an individual rule to be muted, disabled, or deleted] These operations can also be performed in bulk by multi-selecting rules and clicking the *Manage rules* button: [role="screenshot"] -image:management/alerting/images/bulk-mute-disable.png[The Manage rules button lets you mute/unmute, enable/disable, and delete in bulk] +image:images/bulk-mute-disable.png[The Manage rules button lets you mute/unmute, enable/disable, and delete in bulk] + +[float] +=== Required permissions + +Access to rules is granted based on your privileges to alerting-enabled features. See <> for more information. diff --git a/docs/user/alerting/rule-types.asciidoc b/docs/user/alerting/stack-rules.asciidoc similarity index 58% rename from docs/user/alerting/rule-types.asciidoc rename to docs/user/alerting/stack-rules.asciidoc index 44a22c548757c..483834c78806e 100644 --- a/docs/user/alerting/rule-types.asciidoc +++ b/docs/user/alerting/stack-rules.asciidoc @@ -1,14 +1,11 @@ [role="xpack"] -[[rule-types]] -== Rules +[[stack-rules]] +== Stack rule types Kibana provides two types of rules: * Stack rules, which are built into {kib} -* Domain-specific rules, which are registered by {kib} apps. - -[float] -==== Standard stack rules +* <>, which are registered by {kib} apps. {kib} provides two stack rules: @@ -18,17 +15,6 @@ Kibana provides two types of rules: Users require the `all` privilege to access the *Stack Rules* feature and create and edit rules. See <> for more information. -[float] -==== Domain-specific rules - -For domain-specific rules, refer to the documentation for that app. -{kib} supports these rules: - -* {observability-guide}/create-alerts.html[Observability rules] -* {security-guide}/prebuilt-rules.html[Security rules] -* <> -* {ml-docs}/ml-configuring-alerts.html[{ml-cap} rules] beta:[] - [NOTE] ============================================== Some rule types are subscription features, while others are free features. @@ -39,4 +25,3 @@ see {subscriptions}[the subscription page]. include::stack-rules/index-threshold.asciidoc[] include::stack-rules/es-query.asciidoc[] -include::map-rules/geo-rule-types.asciidoc[] diff --git a/docs/user/index.asciidoc b/docs/user/index.asciidoc index 81ded1e54d8fd..47d86004fdc66 100644 --- a/docs/user/index.asciidoc +++ b/docs/user/index.asciidoc @@ -29,6 +29,8 @@ include::ml/index.asciidoc[] include::graph/index.asciidoc[] +include::alerting/index.asciidoc[] + include::{kib-repo-dir}/observability/index.asciidoc[] include::{kib-repo-dir}/apm/index.asciidoc[] @@ -45,8 +47,6 @@ include::{kib-repo-dir}/fleet/fleet.asciidoc[] include::reporting/index.asciidoc[] -include::alerting/index.asciidoc[] - include::api.asciidoc[] include::plugins.asciidoc[] diff --git a/docs/user/management.asciidoc b/docs/user/management.asciidoc index 7c73a80362eb6..83e18734f65d4 100644 --- a/docs/user/management.asciidoc +++ b/docs/user/management.asciidoc @@ -78,9 +78,9 @@ You can add and remove remote clusters, and check their connectivity. [cols="50, 50"] |=== -| <> -| Centrally manage your rules across {kib}. Create and manage reusable -connectors for triggering actions. +| <> +| Centrally <> across {kib}. Create and <> for triggering actions. | <> | Monitor the generation of reports—PDF, PNG, and CSV—and download reports that you previously generated. @@ -182,16 +182,10 @@ next major version of {es}, and then reindex, if needed. include::{kib-repo-dir}/management/advanced-options.asciidoc[] -include::{kib-repo-dir}/management/alerting/rules-and-connectors-intro.asciidoc[] - -include::{kib-repo-dir}/management/alerting/rule-management.asciidoc[] - -include::{kib-repo-dir}/management/alerting/rule-details.asciidoc[] - -include::{kib-repo-dir}/management/alerting/connector-management.asciidoc[] - include::{kib-repo-dir}/management/managing-beats.asciidoc[] +include::{kib-repo-dir}/management/action-types.asciidoc[] + include::{kib-repo-dir}/management/managing-fields.asciidoc[] include::{kib-repo-dir}/management/managing-licenses.asciidoc[] @@ -202,14 +196,14 @@ include::{kib-repo-dir}/management/rollups/create_and_manage_rollups.asciidoc[] include::{kib-repo-dir}/management/managing-saved-objects.asciidoc[] -include::{kib-repo-dir}/management/managing-tags.asciidoc[] - include::security/index.asciidoc[] include::{kib-repo-dir}/management/snapshot-restore/index.asciidoc[] include::{kib-repo-dir}/spaces/index.asciidoc[] +include::{kib-repo-dir}/management/managing-tags.asciidoc[] + include::{kib-repo-dir}/management/upgrade-assistant/index.asciidoc[] include::{kib-repo-dir}/management/watcher-ui/index.asciidoc[] diff --git a/docs/user/monitoring/kibana-alerts.asciidoc b/docs/user/monitoring/kibana-alerts.asciidoc index 2944921edd2ee..58bf419d8d54a 100644 --- a/docs/user/monitoring/kibana-alerts.asciidoc +++ b/docs/user/monitoring/kibana-alerts.asciidoc @@ -20,7 +20,7 @@ analyze past performance. You can also modify active alerts. image::user/monitoring/images/monitoring-kibana-alerts.png["Kibana alerts in the Stack Monitoring app"] To review and modify all the available alerts, use -<> in *{stack-manage-app}*. +<> in *{stack-manage-app}*. [discrete] [[kibana-alerts-cpu-threshold]] diff --git a/examples/search_examples/common/index.ts b/examples/search_examples/common/index.ts index dd953b1ec8982..cc47c0f575973 100644 --- a/examples/search_examples/common/index.ts +++ b/examples/search_examples/common/index.ts @@ -16,6 +16,7 @@ export interface IMyStrategyRequest extends IEsSearchRequest { } export interface IMyStrategyResponse extends IEsSearchResponse { cool: string; + executed_at: number; } export const SERVER_SEARCH_ROUTE_PATH = '/api/examples/search'; diff --git a/examples/search_examples/public/index.scss b/examples/search_examples/public/index.scss index e69de29bb2d1d..b623fecf78640 100644 --- a/examples/search_examples/public/index.scss +++ b/examples/search_examples/public/index.scss @@ -0,0 +1,6 @@ +@import '@elastic/eui/src/global_styling/variables/header'; + +.searchExampleStepDsc { + padding-left: $euiSizeXL; + font-style: italic; +} diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 3bac445581ae7..65d939088515a 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -20,13 +20,13 @@ import { EuiTitle, EuiText, EuiFlexGrid, - EuiFlexGroup, EuiFlexItem, EuiCheckbox, EuiSpacer, EuiCode, EuiComboBox, EuiFormLabel, + EuiTabbedContent, } from '@elastic/eui'; import { CoreStart } from '../../../../src/core/public'; @@ -60,6 +60,11 @@ function getNumeric(fields?: IndexPatternField[]) { return fields?.filter((f) => f.type === 'number' && f.aggregatable); } +function getAggregatableStrings(fields?: IndexPatternField[]) { + if (!fields) return []; + return fields?.filter((f) => f.type === 'string' && f.aggregatable); +} + function formatFieldToComboBox(field?: IndexPatternField | null) { if (!field) return []; return formatFieldsToComboBox([field]); @@ -90,6 +95,9 @@ export const SearchExamplesApp = ({ const [selectedNumericField, setSelectedNumericField] = useState< IndexPatternField | null | undefined >(); + const [selectedBucketField, setSelectedBucketField] = useState< + IndexPatternField | null | undefined + >(); const [request, setRequest] = useState>({}); const [response, setResponse] = useState>({}); @@ -108,10 +116,11 @@ export const SearchExamplesApp = ({ setFields(indexPattern?.fields); }, [indexPattern]); useEffect(() => { + setSelectedBucketField(fields?.length ? getAggregatableStrings(fields)[0] : null); setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null); }, [fields]); - const doAsyncSearch = async (strategy?: string) => { + const doAsyncSearch = async (strategy?: string, sessionId?: string) => { if (!indexPattern || !selectedNumericField) return; // Construct the query portion of the search request @@ -138,6 +147,7 @@ export const SearchExamplesApp = ({ const searchSubscription$ = data.search .search(req, { strategy, + sessionId, }) .subscribe({ next: (res) => { @@ -148,19 +158,30 @@ export const SearchExamplesApp = ({ ? // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response res.rawResponse.aggregations[1].value : undefined; + const isCool = (res as IMyStrategyResponse).cool; + const executedAt = (res as IMyStrategyResponse).executed_at; const message = ( Searched {res.rawResponse.hits.total} documents.
The average of {selectedNumericField!.name} is{' '} {avgResult ? Math.floor(avgResult) : 0}.
- Is it Cool? {String((res as IMyStrategyResponse).cool)} + {isCool ? `Is it Cool? ${isCool}` : undefined} +
+ + {executedAt ? `Executed at? ${executedAt}` : undefined} +
); - notifications.toasts.addSuccess({ - title: 'Query result', - text: mountReactNode(message), - }); + notifications.toasts.addSuccess( + { + title: 'Query result', + text: mountReactNode(message), + }, + { + toastLifeTimeMs: 300000, + } + ); searchSubscription$.unsubscribe(); } else if (isErrorResponse(res)) { // TODO: Make response error status clearer @@ -174,7 +195,7 @@ export const SearchExamplesApp = ({ }); }; - const doSearchSourceSearch = async () => { + const doSearchSourceSearch = async (otherBucket: boolean) => { if (!indexPattern) return; const query = data.query.queryString.getQuery(); @@ -191,28 +212,40 @@ export const SearchExamplesApp = ({ .setField('index', indexPattern) .setField('filter', filters) .setField('query', query) - .setField('fields', selectedFields.length ? selectedFields.map((f) => f.name) : ['*']) + .setField('fields', selectedFields.length ? selectedFields.map((f) => f.name) : ['']) + .setField('size', selectedFields.length ? 100 : 0) .setField('trackTotalHits', 100); - if (selectedNumericField) { - searchSource.setField('aggs', () => { - return data.search.aggs - .createAggConfigs(indexPattern, [ - { type: 'avg', params: { field: selectedNumericField.name } }, - ]) - .toDsl(); + const aggDef = []; + if (selectedBucketField) { + aggDef.push({ + type: 'terms', + schema: 'split', + params: { field: selectedBucketField.name, size: 2, otherBucket }, }); } + if (selectedNumericField) { + aggDef.push({ type: 'avg', params: { field: selectedNumericField.name } }); + } + if (aggDef.length > 0) { + const ac = data.search.aggs.createAggConfigs(indexPattern, aggDef); + searchSource.setField('aggs', ac); + } setRequest(searchSource.getSearchRequestBody()); const res = await searchSource.fetch$().toPromise(); setResponse(res); const message = Searched {res.hits.total} documents.; - notifications.toasts.addSuccess({ - title: 'Query result', - text: mountReactNode(message), - }); + notifications.toasts.addSuccess( + { + title: 'Query result', + text: mountReactNode(message), + }, + { + toastLifeTimeMs: 300000, + } + ); } catch (e) { setResponse(e.body); notifications.toasts.addWarning(`An error has occurred: ${e.message}`); @@ -227,6 +260,10 @@ export const SearchExamplesApp = ({ doAsyncSearch('myStrategy'); }; + const onClientSideSessionCacheClickHandler = () => { + doAsyncSearch('myStrategy', data.search.session.getSessionId()); + }; + const onServerClickHandler = async () => { if (!indexPattern || !selectedNumericField) return; try { @@ -243,10 +280,59 @@ export const SearchExamplesApp = ({ } }; - const onSearchSourceClickHandler = () => { - doSearchSourceSearch(); + const onSearchSourceClickHandler = (withOtherBucket: boolean) => { + doSearchSourceSearch(withOtherBucket); }; + const reqTabs = [ + { + id: 'request', + name: Request, + content: ( + <> + + Search body sent to ES + + {JSON.stringify(request, null, 2)} + + + ), + }, + { + id: 'response', + name: Response, + content: ( + <> + + + + + + {JSON.stringify(response, null, 2)} + + + ), + }, + ]; + return ( @@ -268,59 +354,76 @@ export const SearchExamplesApp = ({ useDefaultBehaviors={true} indexPatterns={indexPattern ? [indexPattern] : undefined} /> - + + + Index Pattern + { + const newIndexPattern = await data.indexPatterns.get(newIndexPatternId); + setIndexPattern(newIndexPattern); + }} + isClearable={false} + data-test-subj="indexPatternSelector" + /> + + + Field (bucket) + { + if (option.length) { + const fld = indexPattern?.getFieldByName(option[0].label); + setSelectedBucketField(fld || null); + } else { + setSelectedBucketField(null); + } + }} + sortMatchesBy="startsWith" + data-test-subj="searchBucketField" + /> + + + Numeric Field (metric) + { + if (option.length) { + const fld = indexPattern?.getFieldByName(option[0].label); + setSelectedNumericField(fld || null); + } else { + setSelectedNumericField(null); + } + }} + sortMatchesBy="startsWith" + data-test-subj="searchMetricField" + /> + + + Fields to queryString + { + const flds = option + .map((opt) => indexPattern?.getFieldByName(opt?.label)) + .filter((f) => f); + setSelectedFields(flds.length ? (flds as IndexPatternField[]) : []); + }} + sortMatchesBy="startsWith" + /> + + + - - - - Index Pattern - { - const newIndexPattern = await data.indexPatterns.get(newIndexPatternId); - setIndexPattern(newIndexPattern); - }} - isClearable={false} - /> - - - Numeric Field to Aggregate - { - const fld = indexPattern?.getFieldByName(option[0].label); - setSelectedNumericField(fld || null); - }} - sortMatchesBy="startsWith" - /> - - - - - Fields to query (leave blank to include all fields) - { - const flds = option - .map((opt) => indexPattern?.getFieldByName(opt?.label)) - .filter((f) => f); - setSelectedFields(flds.length ? (flds as IndexPatternField[]) : []); - }} - sortMatchesBy="startsWith" - /> - - -

@@ -336,15 +439,49 @@ export const SearchExamplesApp = ({ - + + + + onSearchSourceClickHandler(true)} + iconType="play" + data-test-subj="searchSourceWithOther" + > + + + + onSearchSourceClickHandler(false)} + iconType="play" + data-test-subj="searchSourceWithoutOther" + > + + + + + @@ -374,6 +511,45 @@ export const SearchExamplesApp = ({ + +

Client side search session caching

+
+ + data.search.session.start()} + iconType="alert" + data-test-subj="searchExamplesStartSession" + > + + + data.search.session.clear()} + iconType="alert" + data-test-subj="searchExamplesClearSession" + > + + + + + + +

Using search on the server

@@ -391,41 +567,8 @@ export const SearchExamplesApp = ({ - - -

Request

-
- Search body sent to ES - - {JSON.stringify(request, null, 2)} - -
- - -

Response

-
- - - - - {JSON.stringify(response, null, 2)} - + + diff --git a/examples/search_examples/server/my_strategy.ts b/examples/search_examples/server/my_strategy.ts index 2cf039e99f6e9..0a64788960091 100644 --- a/examples/search_examples/server/my_strategy.ts +++ b/examples/search_examples/server/my_strategy.ts @@ -20,6 +20,7 @@ export const mySearchStrategyProvider = ( map((esSearchRes) => ({ ...esSearchRes, cool: request.get_cool ? 'YES' : 'NOPE', + executed_at: new Date().getTime(), })) ), cancel: async (id, options, deps) => { diff --git a/package.json b/package.json index cc2532704114f..01ac344158014 100644 --- a/package.json +++ b/package.json @@ -98,11 +98,11 @@ "dependencies": { "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "28.0.1", + "@elastic/charts": "28.2.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.12.0", - "@elastic/eui": "32.0.4", + "@elastic/eui": "32.1.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", @@ -176,17 +176,6 @@ "angular-sortable-view": "^0.0.17", "angular-ui-ace": "0.2.3", "antlr4ts": "^0.5.0-alpha.3", - "apollo-cache-inmemory": "1.6.2", - "apollo-client": "^2.3.8", - "apollo-link": "^1.2.3", - "apollo-link-error": "^1.1.7", - "apollo-link-http": "^1.5.16", - "apollo-link-http-common": "^0.2.15", - "apollo-link-schema": "^1.1.0", - "apollo-link-state": "^0.4.1", - "apollo-server-core": "^1.3.6", - "apollo-server-errors": "^2.0.2", - "apollo-server-hapi": "^1.3.6", "archiver": "^5.2.0", "axios": "^0.21.1", "base64-js": "^1.3.1", @@ -243,9 +232,7 @@ "glob-all": "^3.2.1", "globby": "^11.0.3", "graphql": "^0.13.2", - "graphql-fields": "^1.0.2", "graphql-tag": "^2.10.3", - "graphql-tools": "^3.0.2", "handlebars": "4.7.7", "he": "^1.2.0", "history": "^4.9.0", @@ -333,7 +320,6 @@ "re2": "^1.15.4", "react": "^16.12.0", "react-ace": "^5.9.0", - "react-apollo": "^2.1.4", "react-beautiful-dnd": "^13.0.0", "react-color": "^2.13.8", "react-datetime": "^2.14.0", @@ -348,7 +334,7 @@ "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", "react-popper-tooltip": "^2.10.1", - "react-query": "^3.12.0", + "react-query": "^3.13.10", "react-resize-detector": "^4.2.0", "react-reverse-portal": "^1.0.4", "react-router-redux": "^4.0.8", @@ -441,7 +427,7 @@ "@babel/runtime": "^7.12.5", "@babel/traverse": "^7.12.12", "@babel/types": "^7.12.12", - "@bazel/ibazel": "^0.14.0", + "@bazel/ibazel": "^0.15.10", "@bazel/typescript": "^3.2.3", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", @@ -454,7 +440,7 @@ "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.5.2", "@kbn/babel-code-parser": "link:packages/kbn-babel-code-parser", - "@kbn/babel-preset": "link:packages/kbn-babel-preset", + "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", "@kbn/dev-utils": "link:packages/kbn-dev-utils", "@kbn/docs-utils": "link:packages/kbn-docs-utils", @@ -728,13 +714,6 @@ "form-data": "^4.0.0", "geckodriver": "^1.22.2", "glob-watcher": "5.0.3", - "graphql-code-generator": "^0.18.2", - "graphql-codegen-add": "^0.18.2", - "graphql-codegen-introspection": "^0.18.2", - "graphql-codegen-typescript-client": "^0.18.2", - "graphql-codegen-typescript-common": "^0.18.2", - "graphql-codegen-typescript-resolvers": "^0.18.2", - "graphql-codegen-typescript-server": "^0.18.2", "grunt": "1.3.0", "grunt-available-tasks": "^0.6.3", "grunt-peg": "^2.0.1", @@ -770,7 +749,7 @@ "jsondiffpatch": "0.4.1", "license-checker": "^16.0.0", "listr": "^0.14.1", - "lmdb-store": "^0.9.0", + "lmdb-store": "^1.2.4", "load-grunt-config": "^3.0.1", "marge": "^1.0.1", "micromatch": "3.1.10", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index fe0e8efe0d44f..e1a85e926f049 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -5,6 +5,7 @@ filegroup( srcs = [ "//packages/elastic-datemath:build", "//packages/kbn-apm-utils:build", + "//packages/kbn-babel-preset:build", "//packages/kbn-config-schema:build", "//packages/kbn-tinymath:build", "//packages/kbn-utility-types:build", diff --git a/packages/kbn-ace/package.json b/packages/kbn-ace/package.json index 30f37b4786f36..5b4b0312aa1ae 100644 --- a/packages/kbn-ace/package.json +++ b/packages/kbn-ace/package.json @@ -10,7 +10,6 @@ "kbn:bootstrap": "yarn build --dev" }, "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/babel-preset": "link:../kbn-babel-preset" + "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-analytics/package.json b/packages/kbn-analytics/package.json index 715f0af96ea3e..5b9db79febd77 100644 --- a/packages/kbn-analytics/package.json +++ b/packages/kbn-analytics/package.json @@ -14,7 +14,6 @@ "kbn:watch": "node scripts/build --source-maps --watch" }, "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/babel-preset": "link:../kbn-babel-preset" + "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-babel-code-parser/package.json b/packages/kbn-babel-code-parser/package.json index bfe01c6eae8e3..a5e05da6f8ee4 100755 --- a/packages/kbn-babel-code-parser/package.json +++ b/packages/kbn-babel-code-parser/package.json @@ -13,8 +13,5 @@ "build": "../../node_modules/.bin/babel src --out-dir target", "kbn:bootstrap": "yarn build --quiet", "kbn:watch": "yarn build --watch" - }, - "dependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset" } } diff --git a/packages/kbn-babel-preset/BUILD.bazel b/packages/kbn-babel-preset/BUILD.bazel new file mode 100644 index 0000000000000..13542ed6e73ad --- /dev/null +++ b/packages/kbn-babel-preset/BUILD.bazel @@ -0,0 +1,63 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-babel-preset" +PKG_REQUIRE_NAME = "@kbn/babel-preset" + +SOURCE_FILES = glob([ + "common_babel_parser_options.js", + "common_preset.js", + "istanbul_preset.js", + "node_preset.js", + "webpack_preset.js", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [ + "@npm//@babel/plugin-proposal-class-properties", + "@npm//@babel/plugin-proposal-export-namespace-from", + "@npm//@babel/plugin-proposal-nullish-coalescing-operator", + "@npm//@babel/plugin-proposal-optional-chaining", + "@npm//@babel/plugin-proposal-private-methods", + "@npm//@babel/preset-env", + "@npm//@babel/preset-react", + "@npm//@babel/preset-typescript", + "@npm//babel-plugin-add-module-exports", + "@npm//babel-plugin-styled-components", +] + +js_library( + name = PKG_BASE_NAME, + srcs = [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + srcs = NPM_MODULE_EXTRA_FILES, + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-cli-dev-mode/src/base_path_proxy_server.test.ts b/packages/kbn-cli-dev-mode/src/base_path_proxy_server.test.ts index c99485c273364..a0afbe3a9b8c9 100644 --- a/packages/kbn-cli-dev-mode/src/base_path_proxy_server.test.ts +++ b/packages/kbn-cli-dev-mode/src/base_path_proxy_server.test.ts @@ -8,6 +8,7 @@ import { Server } from '@hapi/hapi'; import { EMPTY } from 'rxjs'; +import moment from 'moment'; import supertest from 'supertest'; import { getServerOptions, @@ -35,6 +36,7 @@ describe('BasePathProxyServer', () => { config = { host: '127.0.0.1', port: 10012, + shutdownTimeout: moment.duration(30, 'seconds'), keepaliveTimeout: 1000, socketTimeout: 1000, cors: { diff --git a/packages/kbn-cli-dev-mode/src/cli_dev_mode.test.ts b/packages/kbn-cli-dev-mode/src/cli_dev_mode.test.ts index 7b45a2639c668..3471e69846226 100644 --- a/packages/kbn-cli-dev-mode/src/cli_dev_mode.test.ts +++ b/packages/kbn-cli-dev-mode/src/cli_dev_mode.test.ts @@ -108,7 +108,7 @@ it('passes correct args to sub-classes', () => { "bar", "baz", ], - "gracefulTimeout": 5000, + "gracefulTimeout": 30000, "log": , "mapLogLine": [Function], "script": /scripts/kibana, diff --git a/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts b/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts index e867a7276989c..4b1bbb43ba888 100644 --- a/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts +++ b/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts @@ -44,7 +44,7 @@ Rx.merge( .subscribe(exitSignal$); // timeout where the server is allowed to exit gracefully -const GRACEFUL_TIMEOUT = 5000; +const GRACEFUL_TIMEOUT = 30000; export type SomeCliArgs = Pick< CliArgs, diff --git a/packages/kbn-cli-dev-mode/src/config/http_config.ts b/packages/kbn-cli-dev-mode/src/config/http_config.ts index 34f208c28df68..f39bf673f597e 100644 --- a/packages/kbn-cli-dev-mode/src/config/http_config.ts +++ b/packages/kbn-cli-dev-mode/src/config/http_config.ts @@ -8,6 +8,7 @@ import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; import { ICorsConfig, IHttpConfig, ISslConfig, SslConfig, sslSchema } from '@kbn/server-http-tools'; +import { Duration } from 'moment'; export const httpConfigSchema = schema.object( { @@ -22,6 +23,7 @@ export const httpConfigSchema = schema.object( maxPayload: schema.byteSize({ defaultValue: '1048576b', }), + shutdownTimeout: schema.duration({ defaultValue: '30s' }), keepaliveTimeout: schema.number({ defaultValue: 120000, }), @@ -47,6 +49,7 @@ export class HttpConfig implements IHttpConfig { host: string; port: number; maxPayload: ByteSizeValue; + shutdownTimeout: Duration; keepaliveTimeout: number; socketTimeout: number; cors: ICorsConfig; @@ -57,6 +60,7 @@ export class HttpConfig implements IHttpConfig { this.host = rawConfig.host; this.port = rawConfig.port; this.maxPayload = rawConfig.maxPayload; + this.shutdownTimeout = rawConfig.shutdownTimeout; this.keepaliveTimeout = rawConfig.keepaliveTimeout; this.socketTimeout = rawConfig.socketTimeout; this.cors = rawConfig.cors; diff --git a/packages/kbn-cli-dev-mode/src/dev_server.ts b/packages/kbn-cli-dev-mode/src/dev_server.ts index 60a279e456e3d..ca213b117ef34 100644 --- a/packages/kbn-cli-dev-mode/src/dev_server.ts +++ b/packages/kbn-cli-dev-mode/src/dev_server.ts @@ -103,7 +103,7 @@ export class DevServer { /** * Run the Kibana server * - * The observable will error if the child process failes to spawn for some reason, but if + * The observable will error if the child process fails to spawn for some reason, but if * the child process is successfully spawned then the server will be run until it completes * and restart when the watcher indicates it should. In order to restart the server as * quickly as possible we kill it with SIGKILL and spawn the process again. @@ -146,6 +146,7 @@ export class DevServer { const runServer = () => usingServerProcess(this.script, this.argv, (proc) => { this.phase$.next('starting'); + this.ready$.next(false); // observable which emits devServer states containing lines // logged to stdout/stderr, completes when stdio streams complete diff --git a/packages/kbn-dev-utils/package.json b/packages/kbn-dev-utils/package.json index e1990fca4e0bb..87e142c3bece7 100644 --- a/packages/kbn-dev-utils/package.json +++ b/packages/kbn-dev-utils/package.json @@ -17,7 +17,6 @@ "@kbn/utils": "link:../kbn-utils" }, "devDependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset", "@kbn/expect": "link:../kbn-expect" } } \ No newline at end of file diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index c8fe2101bd639..f47f042505cad 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -14,8 +14,5 @@ }, "dependencies": { "@kbn/dev-utils": "link:../kbn-dev-utils" - }, - "devDependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset" } } \ No newline at end of file diff --git a/packages/kbn-i18n/package.json b/packages/kbn-i18n/package.json index d3b4e56fe05d4..570110589490b 100644 --- a/packages/kbn-i18n/package.json +++ b/packages/kbn-i18n/package.json @@ -12,7 +12,6 @@ "kbn:watch": "node scripts/build --watch --source-maps" }, "devDependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset", "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-interpreter/package.json b/packages/kbn-interpreter/package.json index 59a14fa828583..491a7205be210 100644 --- a/packages/kbn-interpreter/package.json +++ b/packages/kbn-interpreter/package.json @@ -13,7 +13,6 @@ "@kbn/i18n": "link:../kbn-i18n" }, "devDependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset", "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-logging/src/ecs/agent.ts b/packages/kbn-logging/src/ecs/agent.ts new file mode 100644 index 0000000000000..0c2e7f7bbe44f --- /dev/null +++ b/packages/kbn-logging/src/ecs/agent.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-agent.html + * + * @internal + */ +export interface EcsAgent { + build?: { original: string }; + ephemeral_id?: string; + id?: string; + name?: string; + type?: string; + version?: string; +} diff --git a/packages/kbn-logging/src/ecs/autonomous_system.ts b/packages/kbn-logging/src/ecs/autonomous_system.ts new file mode 100644 index 0000000000000..85569b7dbabe1 --- /dev/null +++ b/packages/kbn-logging/src/ecs/autonomous_system.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-as.html + * + * @internal + */ +export interface EcsAutonomousSystem { + number?: number; + organization?: { name: string }; +} diff --git a/packages/kbn-logging/src/ecs/base.ts b/packages/kbn-logging/src/ecs/base.ts new file mode 100644 index 0000000000000..cf12cf0ea6e53 --- /dev/null +++ b/packages/kbn-logging/src/ecs/base.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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-base.html + * + * @internal + */ +export interface EcsBase { + ['@timestamp']: string; + labels?: Record; + message?: string; + tags?: string[]; +} diff --git a/packages/kbn-logging/src/ecs/client.ts b/packages/kbn-logging/src/ecs/client.ts new file mode 100644 index 0000000000000..ebee7826104a5 --- /dev/null +++ b/packages/kbn-logging/src/ecs/client.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsAutonomousSystem } from './autonomous_system'; +import { EcsGeo } from './geo'; +import { EcsNestedUser } from './user'; + +interface NestedFields { + as?: EcsAutonomousSystem; + geo?: EcsGeo; + user?: EcsNestedUser; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-client.html + * + * @internal + */ +export interface EcsClient extends NestedFields { + address?: string; + bytes?: number; + domain?: string; + ip?: string; + mac?: string; + nat?: { ip?: string; port?: number }; + packets?: number; + port?: number; + registered_domain?: string; + subdomain?: string; + top_level_domain?: string; +} diff --git a/packages/kbn-logging/src/ecs/cloud.ts b/packages/kbn-logging/src/ecs/cloud.ts new file mode 100644 index 0000000000000..8ef15d40f5529 --- /dev/null +++ b/packages/kbn-logging/src/ecs/cloud.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-cloud.html + * + * @internal + */ +export interface EcsCloud { + account?: { id?: string; name?: string }; + availability_zone?: string; + instance?: { id?: string; name?: string }; + machine?: { type: string }; + project?: { id?: string; name?: string }; + provider?: string; + region?: string; + service?: { name: string }; +} diff --git a/packages/kbn-logging/src/ecs/code_signature.ts b/packages/kbn-logging/src/ecs/code_signature.ts new file mode 100644 index 0000000000000..277c3901a4f8b --- /dev/null +++ b/packages/kbn-logging/src/ecs/code_signature.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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-code_signature.html + * + * @internal + */ +export interface EcsCodeSignature { + exists?: boolean; + signing_id?: string; + status?: string; + subject_name?: string; + team_id?: string; + trusted?: boolean; + valid?: boolean; +} diff --git a/packages/kbn-logging/src/ecs/container.ts b/packages/kbn-logging/src/ecs/container.ts new file mode 100644 index 0000000000000..6c5c85e7107e3 --- /dev/null +++ b/packages/kbn-logging/src/ecs/container.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-container.html + * + * @internal + */ +export interface EcsContainer { + id?: string; + image?: { name?: string; tag?: string[] }; + labels?: Record; + name?: string; + runtime?: string; +} diff --git a/packages/kbn-logging/src/ecs/destination.ts b/packages/kbn-logging/src/ecs/destination.ts new file mode 100644 index 0000000000000..6d2dbc8f431c9 --- /dev/null +++ b/packages/kbn-logging/src/ecs/destination.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsAutonomousSystem } from './autonomous_system'; +import { EcsGeo } from './geo'; +import { EcsNestedUser } from './user'; + +interface NestedFields { + as?: EcsAutonomousSystem; + geo?: EcsGeo; + user?: EcsNestedUser; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-destination.html + * + * @internal + */ +export interface EcsDestination extends NestedFields { + address?: string; + bytes?: number; + domain?: string; + ip?: string; + mac?: string; + nat?: { ip?: string; port?: number }; + packets?: number; + port?: number; + registered_domain?: string; + subdomain?: string; + top_level_domain?: string; +} diff --git a/packages/kbn-logging/src/ecs/dll.ts b/packages/kbn-logging/src/ecs/dll.ts new file mode 100644 index 0000000000000..d9ffa68b3f1a5 --- /dev/null +++ b/packages/kbn-logging/src/ecs/dll.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 { EcsCodeSignature } from './code_signature'; +import { EcsHash } from './hash'; +import { EcsPe } from './pe'; + +interface NestedFields { + code_signature?: EcsCodeSignature; + hash?: EcsHash; + pe?: EcsPe; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-dll.html + * + * @internal + */ +export interface EcsDll extends NestedFields { + name?: string; + path?: string; +} diff --git a/packages/kbn-logging/src/ecs/dns.ts b/packages/kbn-logging/src/ecs/dns.ts new file mode 100644 index 0000000000000..c7a0e7983376c --- /dev/null +++ b/packages/kbn-logging/src/ecs/dns.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-dns.html + * + * @internal + */ +export interface EcsDns { + answers?: Answer[]; + header_flags?: string[]; + id?: number; + op_code?: string; + question?: Question; + resolved_ip?: string[]; + response_code?: string; + type?: string; +} + +interface Answer { + data: string; + class?: string; + name?: string; + ttl?: number; + type?: string; +} + +interface Question { + class?: string; + name?: string; + registered_domain?: string; + subdomain?: string; + top_level_domain?: string; + type?: string; +} diff --git a/packages/kbn-logging/src/ecs/error.ts b/packages/kbn-logging/src/ecs/error.ts new file mode 100644 index 0000000000000..aee010748ddf2 --- /dev/null +++ b/packages/kbn-logging/src/ecs/error.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-error.html + * + * @internal + */ +export interface EcsError { + code?: string; + id?: string; + message?: string; + stack_trace?: string; + type?: string; +} diff --git a/packages/kbn-logging/src/ecs/event.ts b/packages/kbn-logging/src/ecs/event.ts new file mode 100644 index 0000000000000..bf711410a9dd7 --- /dev/null +++ b/packages/kbn-logging/src/ecs/event.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-event.html + * + * @internal + */ +export interface EcsEvent { + action?: string; + category?: EcsEventCategory[]; + code?: string; + created?: string; + dataset?: string; + duration?: number; + end?: string; + hash?: string; + id?: string; + ingested?: string; + kind?: EcsEventKind; + module?: string; + original?: string; + outcome?: EcsEventOutcome; + provider?: string; + reason?: string; + reference?: string; + risk_score?: number; + risk_score_norm?: number; + sequence?: number; + severity?: number; + start?: string; + timezone?: string; + type?: EcsEventType[]; + url?: string; +} + +/** + * @public + */ +export type EcsEventCategory = + | 'authentication' + | 'configuration' + | 'database' + | 'driver' + | 'file' + | 'host' + | 'iam' + | 'intrusion_detection' + | 'malware' + | 'network' + | 'package' + | 'process' + | 'registry' + | 'session' + | 'web'; + +/** + * @public + */ +export type EcsEventKind = 'alert' | 'event' | 'metric' | 'state' | 'pipeline_error' | 'signal'; + +/** + * @public + */ +export type EcsEventOutcome = 'failure' | 'success' | 'unknown'; + +/** + * @public + */ +export type EcsEventType = + | 'access' + | 'admin' + | 'allowed' + | 'change' + | 'connection' + | 'creation' + | 'deletion' + | 'denied' + | 'end' + | 'error' + | 'group' + | 'info' + | 'installation' + | 'protocol' + | 'start' + | 'user'; diff --git a/packages/kbn-logging/src/ecs/file.ts b/packages/kbn-logging/src/ecs/file.ts new file mode 100644 index 0000000000000..c09121607e0a4 --- /dev/null +++ b/packages/kbn-logging/src/ecs/file.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsCodeSignature } from './code_signature'; +import { EcsHash } from './hash'; +import { EcsPe } from './pe'; +import { EcsX509 } from './x509'; + +interface NestedFields { + code_signature?: EcsCodeSignature; + hash?: EcsHash; + pe?: EcsPe; + x509?: EcsX509; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-file.html + * + * @internal + */ +export interface EcsFile extends NestedFields { + accessed?: string; + attributes?: string[]; + created?: string; + ctime?: string; + device?: string; + directory?: string; + drive_letter?: string; + extension?: string; + gid?: string; + group?: string; + inode?: string; + // Technically this is a known list, but it's massive, so we'll just accept a string for now :) + // https://www.iana.org/assignments/media-types/media-types.xhtml + mime_type?: string; + mode?: string; + mtime?: string; + name?: string; + owner?: string; + path?: string; + 'path.text'?: string; + size?: number; + target_path?: string; + 'target_path.text'?: string; + type?: string; + uid?: string; +} diff --git a/packages/kbn-logging/src/ecs/geo.ts b/packages/kbn-logging/src/ecs/geo.ts new file mode 100644 index 0000000000000..85d45ca803aee --- /dev/null +++ b/packages/kbn-logging/src/ecs/geo.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-geo.html + * + * @internal + */ +export interface EcsGeo { + city_name?: string; + continent_code?: string; + continent_name?: string; + country_iso_code?: string; + country_name?: string; + location?: GeoPoint; + name?: string; + postal_code?: string; + region_iso_code?: string; + region_name?: string; + timezone?: string; +} + +interface GeoPoint { + lat: number; + lon: number; +} diff --git a/packages/kbn-logging/src/ecs/group.ts b/packages/kbn-logging/src/ecs/group.ts new file mode 100644 index 0000000000000..e1bc339964fc0 --- /dev/null +++ b/packages/kbn-logging/src/ecs/group.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-group.html + * + * @internal + */ +export interface EcsGroup { + domain?: string; + id?: string; + name?: string; +} diff --git a/packages/kbn-logging/src/ecs/hash.ts b/packages/kbn-logging/src/ecs/hash.ts new file mode 100644 index 0000000000000..2ecd49f1ca092 --- /dev/null +++ b/packages/kbn-logging/src/ecs/hash.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-hash.html + * + * @internal + */ +export interface EcsHash { + md5?: string; + sha1?: string; + sha256?: string; + sha512?: string; + ssdeep?: string; +} diff --git a/packages/kbn-logging/src/ecs/host.ts b/packages/kbn-logging/src/ecs/host.ts new file mode 100644 index 0000000000000..085db30e13e7e --- /dev/null +++ b/packages/kbn-logging/src/ecs/host.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsGeo } from './geo'; +import { EcsOs } from './os'; +import { EcsNestedUser } from './user'; + +interface NestedFields { + geo?: EcsGeo; + os?: EcsOs; + /** @deprecated */ + user?: EcsNestedUser; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-host.html + * + * @internal + */ +export interface EcsHost extends NestedFields { + architecture?: string; + cpu?: { usage: number }; + disk?: Disk; + domain?: string; + hostname?: string; + id?: string; + ip?: string[]; + mac?: string[]; + name?: string; + network?: Network; + type?: string; + uptime?: number; +} + +interface Disk { + read?: { bytes: number }; + write?: { bytes: number }; +} + +interface Network { + egress?: { bytes?: number; packets?: number }; + ingress?: { bytes?: number; packets?: number }; +} diff --git a/packages/kbn-logging/src/ecs/http.ts b/packages/kbn-logging/src/ecs/http.ts new file mode 100644 index 0000000000000..c734c93318f5c --- /dev/null +++ b/packages/kbn-logging/src/ecs/http.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-http.html + * + * @internal + */ +export interface EcsHttp { + request?: Request; + response?: Response; + version?: string; +} + +interface Request { + body?: { bytes?: number; content?: string }; + bytes?: number; + id?: string; + // We can't provide predefined values here because ECS requires preserving the + // original casing for anomaly detection use cases. + method?: string; + mime_type?: string; + referrer?: string; +} + +interface Response { + body?: { bytes?: number; content?: string }; + bytes?: number; + mime_type?: string; + status_code?: number; +} diff --git a/packages/kbn-logging/src/ecs/index.ts b/packages/kbn-logging/src/ecs/index.ts new file mode 100644 index 0000000000000..30da3baa43b72 --- /dev/null +++ b/packages/kbn-logging/src/ecs/index.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsBase } from './base'; + +import { EcsAgent } from './agent'; +import { EcsAutonomousSystem } from './autonomous_system'; +import { EcsClient } from './client'; +import { EcsCloud } from './cloud'; +import { EcsContainer } from './container'; +import { EcsDestination } from './destination'; +import { EcsDns } from './dns'; +import { EcsError } from './error'; +import { EcsEvent } from './event'; +import { EcsFile } from './file'; +import { EcsGroup } from './group'; +import { EcsHost } from './host'; +import { EcsHttp } from './http'; +import { EcsLog } from './log'; +import { EcsNetwork } from './network'; +import { EcsObserver } from './observer'; +import { EcsOrganization } from './organization'; +import { EcsPackage } from './package'; +import { EcsProcess } from './process'; +import { EcsRegistry } from './registry'; +import { EcsRelated } from './related'; +import { EcsRule } from './rule'; +import { EcsServer } from './server'; +import { EcsService } from './service'; +import { EcsSource } from './source'; +import { EcsThreat } from './threat'; +import { EcsTls } from './tls'; +import { EcsTracing } from './tracing'; +import { EcsUrl } from './url'; +import { EcsUser } from './user'; +import { EcsUserAgent } from './user_agent'; +import { EcsVulnerability } from './vulnerability'; + +export { EcsEventCategory, EcsEventKind, EcsEventOutcome, EcsEventType } from './event'; + +interface EcsField { + /** + * These typings were written as of ECS 1.9.0. + * Don't change this value without checking the rest + * of the types to conform to that ECS version. + * + * https://www.elastic.co/guide/en/ecs/1.9/index.html + */ + version: '1.9.0'; +} + +/** + * Represents the full ECS schema. + * + * @public + */ +export type Ecs = EcsBase & + EcsTracing & { + ecs: EcsField; + + agent?: EcsAgent; + as?: EcsAutonomousSystem; + client?: EcsClient; + cloud?: EcsCloud; + container?: EcsContainer; + destination?: EcsDestination; + dns?: EcsDns; + error?: EcsError; + event?: EcsEvent; + file?: EcsFile; + group?: EcsGroup; + host?: EcsHost; + http?: EcsHttp; + log?: EcsLog; + network?: EcsNetwork; + observer?: EcsObserver; + organization?: EcsOrganization; + package?: EcsPackage; + process?: EcsProcess; + registry?: EcsRegistry; + related?: EcsRelated; + rule?: EcsRule; + server?: EcsServer; + service?: EcsService; + source?: EcsSource; + threat?: EcsThreat; + tls?: EcsTls; + url?: EcsUrl; + user?: EcsUser; + user_agent?: EcsUserAgent; + vulnerability?: EcsVulnerability; + }; diff --git a/packages/kbn-logging/src/ecs/interface.ts b/packages/kbn-logging/src/ecs/interface.ts new file mode 100644 index 0000000000000..49b33e8338184 --- /dev/null +++ b/packages/kbn-logging/src/ecs/interface.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-interface.html + * + * @internal + */ +export interface EcsInterface { + alias?: string; + id?: string; + name?: string; +} diff --git a/packages/kbn-logging/src/ecs/log.ts b/packages/kbn-logging/src/ecs/log.ts new file mode 100644 index 0000000000000..8bc2e4982e96c --- /dev/null +++ b/packages/kbn-logging/src/ecs/log.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-log.html + * + * @internal + */ +export interface EcsLog { + file?: { path: string }; + level?: string; + logger?: string; + origin?: Origin; + original?: string; + syslog?: Syslog; +} + +interface Origin { + file?: { line?: number; name?: string }; + function?: string; +} + +interface Syslog { + facility?: { code?: number; name?: string }; + priority?: number; + severity?: { code?: number; name?: string }; +} diff --git a/packages/kbn-logging/src/ecs/network.ts b/packages/kbn-logging/src/ecs/network.ts new file mode 100644 index 0000000000000..912427b6cdb7e --- /dev/null +++ b/packages/kbn-logging/src/ecs/network.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsVlan } from './vlan'; + +interface NestedFields { + inner?: { vlan?: EcsVlan }; + vlan?: EcsVlan; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-network.html + * + * @internal + */ +export interface EcsNetwork extends NestedFields { + application?: string; + bytes?: number; + community_id?: string; + direction?: string; + forwarded_ip?: string; + iana_number?: string; + name?: string; + packets?: number; + protocol?: string; + transport?: string; + type?: string; +} diff --git a/packages/kbn-logging/src/ecs/observer.ts b/packages/kbn-logging/src/ecs/observer.ts new file mode 100644 index 0000000000000..be2636d15dcdf --- /dev/null +++ b/packages/kbn-logging/src/ecs/observer.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsGeo } from './geo'; +import { EcsInterface } from './interface'; +import { EcsOs } from './os'; +import { EcsVlan } from './vlan'; + +interface NestedFields { + egress?: NestedEgressFields; + geo?: EcsGeo; + ingress?: NestedIngressFields; + os?: EcsOs; +} + +interface NestedEgressFields { + interface?: EcsInterface; + vlan?: EcsVlan; +} + +interface NestedIngressFields { + interface?: EcsInterface; + vlan?: EcsVlan; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-observer.html + * + * @internal + */ +export interface EcsObserver extends NestedFields { + egress?: Egress; + hostname?: string; + ingress?: Ingress; + ip?: string[]; + mac?: string[]; + name?: string; + product?: string; + serial_number?: string; + type?: string; + vendor?: string; + version?: string; +} + +interface Egress extends NestedEgressFields { + zone?: string; +} + +interface Ingress extends NestedIngressFields { + zone?: string; +} diff --git a/packages/kbn-logging/src/ecs/organization.ts b/packages/kbn-logging/src/ecs/organization.ts new file mode 100644 index 0000000000000..370e6b2646a2f --- /dev/null +++ b/packages/kbn-logging/src/ecs/organization.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-organization.html + * + * @internal + */ +export interface EcsOrganization { + id?: string; + name?: string; +} diff --git a/packages/kbn-logging/src/ecs/os.ts b/packages/kbn-logging/src/ecs/os.ts new file mode 100644 index 0000000000000..342eb14264fd3 --- /dev/null +++ b/packages/kbn-logging/src/ecs/os.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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-os.html + * + * @internal + */ +export interface EcsOs { + family?: string; + full?: string; + kernel?: string; + name?: string; + platform?: string; + type?: string; + version?: string; +} diff --git a/packages/kbn-logging/src/ecs/package.ts b/packages/kbn-logging/src/ecs/package.ts new file mode 100644 index 0000000000000..10528066f3f29 --- /dev/null +++ b/packages/kbn-logging/src/ecs/package.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-package.html + * + * @internal + */ +export interface EcsPackage { + architecture?: string; + build_version?: string; + checksum?: string; + description?: string; + install_scope?: string; + installed?: string; + license?: string; + name?: string; + path?: string; + reference?: string; + size?: number; + type?: string; + version?: string; +} diff --git a/packages/kbn-logging/src/ecs/pe.ts b/packages/kbn-logging/src/ecs/pe.ts new file mode 100644 index 0000000000000..bd53b7048a50d --- /dev/null +++ b/packages/kbn-logging/src/ecs/pe.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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-pe.html + * + * @internal + */ +export interface EcsPe { + architecture?: string; + company?: string; + description?: string; + file_version?: string; + imphash?: string; + original_file_name?: string; + product?: string; +} diff --git a/packages/kbn-logging/src/ecs/process.ts b/packages/kbn-logging/src/ecs/process.ts new file mode 100644 index 0000000000000..9a034c30fd531 --- /dev/null +++ b/packages/kbn-logging/src/ecs/process.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsCodeSignature } from './code_signature'; +import { EcsHash } from './hash'; +import { EcsPe } from './pe'; + +interface NestedFields { + code_signature?: EcsCodeSignature; + hash?: EcsHash; + parent?: EcsProcess; + pe?: EcsPe; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-process.html + * + * @internal + */ +export interface EcsProcess extends NestedFields { + args?: string[]; + args_count?: number; + command_line?: string; + entity_id?: string; + executable?: string; + exit_code?: number; + name?: string; + pgid?: number; + pid?: number; + ppid?: number; + start?: string; + thread?: { id?: number; name?: string }; + title?: string; + uptime?: number; + working_directory?: string; +} diff --git a/packages/kbn-logging/src/ecs/registry.ts b/packages/kbn-logging/src/ecs/registry.ts new file mode 100644 index 0000000000000..ba7ef699e2cdb --- /dev/null +++ b/packages/kbn-logging/src/ecs/registry.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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-registry.html + * + * @internal + */ +export interface EcsRegistry { + data?: Data; + hive?: string; + key?: string; + path?: string; + value?: string; +} + +interface Data { + bytes?: string; + strings?: string[]; + type?: string; +} diff --git a/packages/kbn-logging/src/ecs/related.ts b/packages/kbn-logging/src/ecs/related.ts new file mode 100644 index 0000000000000..33c3ff50540ce --- /dev/null +++ b/packages/kbn-logging/src/ecs/related.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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-related.html + * + * @internal + */ +export interface EcsRelated { + hash?: string[]; + hosts?: string[]; + ip?: string[]; + user?: string[]; +} diff --git a/packages/kbn-logging/src/ecs/rule.ts b/packages/kbn-logging/src/ecs/rule.ts new file mode 100644 index 0000000000000..c6bf1ce96552a --- /dev/null +++ b/packages/kbn-logging/src/ecs/rule.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-rule.html + * + * @internal + */ +export interface EcsRule { + author?: string[]; + category?: string; + description?: string; + id?: string; + license?: string; + name?: string; + reference?: string; + ruleset?: string; + uuid?: string; + version?: string; +} diff --git a/packages/kbn-logging/src/ecs/server.ts b/packages/kbn-logging/src/ecs/server.ts new file mode 100644 index 0000000000000..9b2a9b1a11b42 --- /dev/null +++ b/packages/kbn-logging/src/ecs/server.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsAutonomousSystem } from './autonomous_system'; +import { EcsGeo } from './geo'; +import { EcsNestedUser } from './user'; + +interface NestedFields { + as?: EcsAutonomousSystem; + geo?: EcsGeo; + user?: EcsNestedUser; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-server.html + * + * @internal + */ +export interface EcsServer extends NestedFields { + address?: string; + bytes?: number; + domain?: string; + ip?: string; + mac?: string; + nat?: { ip?: string; port?: number }; + packets?: number; + port?: number; + registered_domain?: string; + subdomain?: string; + top_level_domain?: string; +} diff --git a/packages/kbn-logging/src/ecs/service.ts b/packages/kbn-logging/src/ecs/service.ts new file mode 100644 index 0000000000000..4cd79e928c076 --- /dev/null +++ b/packages/kbn-logging/src/ecs/service.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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-service.html + * + * @internal + */ +export interface EcsService { + ephemeral_id?: string; + id?: string; + name?: string; + node?: { name: string }; + state?: string; + type?: string; + version?: string; +} diff --git a/packages/kbn-logging/src/ecs/source.ts b/packages/kbn-logging/src/ecs/source.ts new file mode 100644 index 0000000000000..9ec7e2521d0b9 --- /dev/null +++ b/packages/kbn-logging/src/ecs/source.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsAutonomousSystem } from './autonomous_system'; +import { EcsGeo } from './geo'; +import { EcsNestedUser } from './user'; + +interface NestedFields { + as?: EcsAutonomousSystem; + geo?: EcsGeo; + user?: EcsNestedUser; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-source.html + * + * @internal + */ +export interface EcsSource extends NestedFields { + address?: string; + bytes?: number; + domain?: string; + ip?: string; + mac?: string; + nat?: { ip?: string; port?: number }; + packets?: number; + port?: number; + registered_domain?: string; + subdomain?: string; + top_level_domain?: string; +} diff --git a/packages/kbn-logging/src/ecs/threat.ts b/packages/kbn-logging/src/ecs/threat.ts new file mode 100644 index 0000000000000..ac6033949fccd --- /dev/null +++ b/packages/kbn-logging/src/ecs/threat.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-threat.html + * + * @internal + */ +export interface EcsThreat { + framework?: string; + tactic?: Tactic; + technique?: Technique; +} + +interface Tactic { + id?: string[]; + name?: string[]; + reference?: string[]; +} + +interface Technique { + id?: string[]; + name?: string[]; + reference?: string[]; + subtechnique?: Technique; +} diff --git a/packages/kbn-logging/src/ecs/tls.ts b/packages/kbn-logging/src/ecs/tls.ts new file mode 100644 index 0000000000000..b04d03d650908 --- /dev/null +++ b/packages/kbn-logging/src/ecs/tls.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 { EcsX509 } from './x509'; + +interface NestedClientFields { + x509?: EcsX509; +} + +interface NestedServerFields { + x509?: EcsX509; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-tls.html + * + * @internal + */ +export interface EcsTls { + cipher?: string; + client?: Client; + curve?: string; + established?: boolean; + next_protocol?: string; + resumed?: boolean; + server?: Server; + version?: string; + version_protocol?: string; +} + +interface Client extends NestedClientFields { + certificate?: string; + certificate_chain?: string[]; + hash?: Hash; + issuer?: string; + ja3?: string; + not_after?: string; + not_before?: string; + server_name?: string; + subject?: string; + supported_ciphers?: string[]; +} + +interface Server extends NestedServerFields { + certificate?: string; + certificate_chain?: string[]; + hash?: Hash; + issuer?: string; + ja3s?: string; + not_after?: string; + not_before?: string; + subject?: string; +} + +interface Hash { + md5?: string; + sha1?: string; + sha256?: string; +} diff --git a/packages/kbn-logging/src/ecs/tracing.ts b/packages/kbn-logging/src/ecs/tracing.ts new file mode 100644 index 0000000000000..1abbbd4b4c8a2 --- /dev/null +++ b/packages/kbn-logging/src/ecs/tracing.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * Unlike other ECS field sets, tracing fields are not nested under the field + * set name (i.e. `trace.id` is valid, `tracing.trace.id` is not). So, like + * the base fields, we will need to do an intersection with these types at + * the root level. + * + * https://www.elastic.co/guide/en/ecs/1.9/ecs-tracing.html + * + * @internal + */ +export interface EcsTracing { + span?: { id?: string }; + trace?: { id?: string }; + transaction?: { id?: string }; +} diff --git a/packages/kbn-logging/src/ecs/url.ts b/packages/kbn-logging/src/ecs/url.ts new file mode 100644 index 0000000000000..5985b28a4f6c3 --- /dev/null +++ b/packages/kbn-logging/src/ecs/url.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-url.html + * + * @internal + */ +export interface EcsUrl { + domain?: string; + extension?: string; + fragment?: string; + full?: string; + original?: string; + password?: string; + path?: string; + port?: number; + query?: string; + registered_domain?: string; + scheme?: string; + subdomain?: string; + top_level_domain?: string; + username?: string; +} diff --git a/packages/kbn-logging/src/ecs/user.ts b/packages/kbn-logging/src/ecs/user.ts new file mode 100644 index 0000000000000..3ab0c946b49b7 --- /dev/null +++ b/packages/kbn-logging/src/ecs/user.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsGroup } from './group'; + +interface NestedFields { + group?: EcsGroup; +} + +/** + * `User` is unlike most other fields which can be reused in multiple places + * in that ECS places restrictions on which individual properties can be reused; + * + * Specifically, `changes`, `effective`, and `target` may be used if `user` is + * placed at the root level, but not if it is nested inside another field like + * `destination`. A more detailed explanation of these nuances can be found at: + * + * https://www.elastic.co/guide/en/ecs/1.9/ecs-user-usage.html + * + * As a result, we need to export a separate `NestedUser` type to import into + * other interfaces internally. This contains the reusable subset of properties + * from `User`. + * + * @internal + */ +export interface EcsNestedUser extends NestedFields { + domain?: string; + email?: string; + full_name?: string; + hash?: string; + id?: string; + name?: string; + roles?: string[]; +} + +/** + * @internal + */ +export interface EcsUser extends EcsNestedUser { + changes?: EcsNestedUser; + effective?: EcsNestedUser; + target?: EcsNestedUser; +} diff --git a/packages/kbn-logging/src/ecs/user_agent.ts b/packages/kbn-logging/src/ecs/user_agent.ts new file mode 100644 index 0000000000000..f77b3ba9e1f0f --- /dev/null +++ b/packages/kbn-logging/src/ecs/user_agent.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsOs } from './os'; + +interface NestedFields { + os?: EcsOs; +} + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-user_agent.html + * + * @internal + */ +export interface EcsUserAgent extends NestedFields { + device?: { name: string }; + name?: string; + original?: string; + version?: string; +} diff --git a/packages/kbn-logging/src/ecs/vlan.ts b/packages/kbn-logging/src/ecs/vlan.ts new file mode 100644 index 0000000000000..646f8ee17fd03 --- /dev/null +++ b/packages/kbn-logging/src/ecs/vlan.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-vlan.html + * + * @internal + */ +export interface EcsVlan { + id?: string; + name?: string; +} diff --git a/packages/kbn-logging/src/ecs/vulnerability.ts b/packages/kbn-logging/src/ecs/vulnerability.ts new file mode 100644 index 0000000000000..2c26d557d2ba9 --- /dev/null +++ b/packages/kbn-logging/src/ecs/vulnerability.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-vulnerability.html + * + * @internal + */ +export interface EcsVulnerability { + category?: string[]; + classification?: string; + description?: string; + enumeration?: string; + id?: string; + reference?: string; + report_id?: string; + scanner?: { vendor: string }; + score?: Score; + severity?: string; +} + +interface Score { + base?: number; + environmental?: number; + temporal?: number; + version?: string; +} diff --git a/packages/kbn-logging/src/ecs/x509.ts b/packages/kbn-logging/src/ecs/x509.ts new file mode 100644 index 0000000000000..35bc1b458579a --- /dev/null +++ b/packages/kbn-logging/src/ecs/x509.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * https://www.elastic.co/guide/en/ecs/1.9/ecs-x509.html + * + * @internal + */ +export interface EcsX509 { + alternative_names?: string[]; + issuer?: Issuer; + not_after?: string; + not_before?: string; + public_key_algorithm?: string; + public_key_curve?: string; + public_key_exponent?: number; + public_key_size?: number; + serial_number?: string; + signature_algorithm?: string; + subject?: Subject; + version_number?: string; +} + +interface Issuer { + common_name?: string[]; + country?: string[]; + distinguished_name?: string; + locality?: string[]; + organization?: string[]; + organizational_unit?: string[]; + state_or_province?: string[]; +} + +interface Subject { + common_name?: string[]; + country?: string[]; + distinguished_name?: string; + locality?: string[]; + organization?: string[]; + organizational_unit?: string[]; + state_or_province?: string[]; +} diff --git a/packages/kbn-logging/src/index.ts b/packages/kbn-logging/src/index.ts index 048a95395e5c6..075e18f99afe3 100644 --- a/packages/kbn-logging/src/index.ts +++ b/packages/kbn-logging/src/index.ts @@ -8,7 +8,9 @@ export { LogLevel, LogLevelId } from './log_level'; export { LogRecord } from './log_record'; -export { Logger, LogMeta } from './logger'; +export { Logger } from './logger'; +export { LogMeta } from './log_meta'; export { LoggerFactory } from './logger_factory'; export { Layout } from './layout'; export { Appender, DisposableAppender } from './appenders'; +export { Ecs, EcsEventCategory, EcsEventKind, EcsEventOutcome, EcsEventType } from './ecs'; diff --git a/packages/kbn-logging/src/log_meta.ts b/packages/kbn-logging/src/log_meta.ts new file mode 100644 index 0000000000000..7822792c7fbeb --- /dev/null +++ b/packages/kbn-logging/src/log_meta.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EcsBase } from './ecs/base'; + +import { EcsAgent } from './ecs/agent'; +import { EcsAutonomousSystem } from './ecs/autonomous_system'; +import { EcsClient } from './ecs/client'; +import { EcsCloud } from './ecs/cloud'; +import { EcsContainer } from './ecs/container'; +import { EcsDestination } from './ecs/destination'; +import { EcsDns } from './ecs/dns'; +import { EcsError } from './ecs/error'; +import { EcsEvent } from './ecs/event'; +import { EcsFile } from './ecs/file'; +import { EcsGroup } from './ecs/group'; +import { EcsHost } from './ecs/host'; +import { EcsHttp } from './ecs/http'; +import { EcsLog } from './ecs/log'; +import { EcsNetwork } from './ecs/network'; +import { EcsObserver } from './ecs/observer'; +import { EcsOrganization } from './ecs/organization'; +import { EcsPackage } from './ecs/package'; +import { EcsProcess } from './ecs/process'; +import { EcsRegistry } from './ecs/registry'; +import { EcsRelated } from './ecs/related'; +import { EcsRule } from './ecs/rule'; +import { EcsServer } from './ecs/server'; +import { EcsService } from './ecs/service'; +import { EcsSource } from './ecs/source'; +import { EcsThreat } from './ecs/threat'; +import { EcsTls } from './ecs/tls'; +import { EcsTracing } from './ecs/tracing'; +import { EcsUrl } from './ecs/url'; +import { EcsUser } from './ecs/user'; +import { EcsUserAgent } from './ecs/user_agent'; +import { EcsVulnerability } from './ecs/vulnerability'; + +/** + * Represents the ECS schema with the following reserved keys excluded: + * - `ecs` + * - `@timestamp` + * - `message` + * - `log.level` + * - `log.logger` + * + * @public + */ +export type LogMeta = Omit & + EcsTracing & { + agent?: EcsAgent; + as?: EcsAutonomousSystem; + client?: EcsClient; + cloud?: EcsCloud; + container?: EcsContainer; + destination?: EcsDestination; + dns?: EcsDns; + error?: EcsError; + event?: EcsEvent; + file?: EcsFile; + group?: EcsGroup; + host?: EcsHost; + http?: EcsHttp; + log?: Omit; + network?: EcsNetwork; + observer?: EcsObserver; + organization?: EcsOrganization; + package?: EcsPackage; + process?: EcsProcess; + registry?: EcsRegistry; + related?: EcsRelated; + rule?: EcsRule; + server?: EcsServer; + service?: EcsService; + source?: EcsSource; + threat?: EcsThreat; + tls?: EcsTls; + url?: EcsUrl; + user?: EcsUser; + user_agent?: EcsUserAgent; + vulnerability?: EcsVulnerability; + }; diff --git a/packages/kbn-logging/src/logger.ts b/packages/kbn-logging/src/logger.ts index dad4fb07c6cfa..fda3cf45b9d79 100644 --- a/packages/kbn-logging/src/logger.ts +++ b/packages/kbn-logging/src/logger.ts @@ -6,17 +6,9 @@ * Side Public License, v 1. */ +import { LogMeta } from './log_meta'; import { LogRecord } from './log_record'; -/** - * Contextual metadata - * - * @public - */ -export interface LogMeta { - [key: string]: any; -} - /** * Logger exposes all the necessary methods to log any type of information and * this is the interface used by the logging consumers including plugins. @@ -30,28 +22,28 @@ export interface Logger { * @param message - The log message * @param meta - */ - trace(message: string, meta?: LogMeta): void; + trace(message: string, meta?: Meta): void; /** * Log messages useful for debugging and interactive investigation * @param message - The log message * @param meta - */ - debug(message: string, meta?: LogMeta): void; + debug(message: string, meta?: Meta): void; /** * Logs messages related to general application flow * @param message - The log message * @param meta - */ - info(message: string, meta?: LogMeta): void; + info(message: string, meta?: Meta): void; /** * Logs abnormal or unexpected errors or messages * @param errorOrMessage - An Error object or message string to log * @param meta - */ - warn(errorOrMessage: string | Error, meta?: LogMeta): void; + warn(errorOrMessage: string | Error, meta?: Meta): void; /** * Logs abnormal or unexpected errors or messages that caused a failure in the application flow @@ -59,7 +51,7 @@ export interface Logger { * @param errorOrMessage - An Error object or message string to log * @param meta - */ - error(errorOrMessage: string | Error, meta?: LogMeta): void; + error(errorOrMessage: string | Error, meta?: Meta): void; /** * Logs abnormal or unexpected errors or messages that caused an unrecoverable failure @@ -67,7 +59,7 @@ export interface Logger { * @param errorOrMessage - An Error object or message string to log * @param meta - */ - fatal(errorOrMessage: string | Error, meta?: LogMeta): void; + fatal(errorOrMessage: string | Error, meta?: Meta): void; /** @internal */ log(record: LogRecord): void; diff --git a/packages/kbn-monaco/package.json b/packages/kbn-monaco/package.json index bdf36915bab3a..f4309e08f5bdb 100644 --- a/packages/kbn-monaco/package.json +++ b/packages/kbn-monaco/package.json @@ -11,7 +11,6 @@ "build:antlr4ts": "../../node_modules/antlr4ts-cli/antlr4ts ./src/painless/antlr/painless_lexer.g4 ./src/painless/antlr/painless_parser.g4 && node ./scripts/fix_generated_antlr.js" }, "devDependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset", "@kbn/dev-utils": "link:../kbn-dev-utils" }, "dependencies": { diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f42ca7451601b..1d19387494136 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -9,7 +9,7 @@ pageLoadAssetSize: charts: 195358 cloud: 21076 console: 46091 - core: 397521 + core: 413500 crossClusterReplication: 65408 dashboard: 374194 dashboardEnhanced: 65646 @@ -106,6 +106,7 @@ pageLoadAssetSize: indexPatternFieldEditor: 90489 osquery: 107090 fileUpload: 25664 + fileDataVisualizer: 27530 banners: 17946 mapsEms: 26072 timelines: 28613 diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index ac73fbc0fc16a..3c14d98755a32 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -11,7 +11,6 @@ "kbn:watch": "yarn build --watch" }, "dependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset", "@kbn/config": "link:../kbn-config", "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/std": "link:../kbn-std", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index e6cdd52686656..c0afb92b859cd 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -48512,7 +48512,13 @@ async function runBazel(bazelArgs, offline = false, runOpts = {}) { await runBazelCommandWithRunner('bazel', bazelArgs, offline, runOpts); } async function runIBazel(bazelArgs, offline = false, runOpts = {}) { - await runBazelCommandWithRunner('ibazel', bazelArgs, offline, runOpts); + const extendedEnv = _objectSpread({ + IBAZEL_USE_LEGACY_WATCHER: '0' + }, runOpts === null || runOpts === void 0 ? void 0 : runOpts.env); + + await runBazelCommandWithRunner('ibazel', bazelArgs, offline, _objectSpread(_objectSpread({}, runOpts), {}, { + env: extendedEnv + })); } /***/ }), @@ -59743,7 +59749,7 @@ const WatchBazelCommand = { // Note: --run_output=false arg will disable the iBazel notifications about gazelle and buildozer when running it // Can also be solved by adding a root `.bazel_fix_commands.json` but its not needed at the moment - await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_0__["runIBazel"])(['--run_output=false', 'build', '//packages:build'], runOffline); + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_0__["runIBazel"])(['--run_output=false', 'build', '//packages:build', '--show_result=1'], runOffline); } }; diff --git a/packages/kbn-pm/src/commands/watch_bazel.ts b/packages/kbn-pm/src/commands/watch_bazel.ts index 1273562dd2511..6d57ce66854fd 100644 --- a/packages/kbn-pm/src/commands/watch_bazel.ts +++ b/packages/kbn-pm/src/commands/watch_bazel.ts @@ -20,6 +20,9 @@ export const WatchBazelCommand: ICommand = { // // Note: --run_output=false arg will disable the iBazel notifications about gazelle and buildozer when running it // Can also be solved by adding a root `.bazel_fix_commands.json` but its not needed at the moment - await runIBazel(['--run_output=false', 'build', '//packages:build'], runOffline); + await runIBazel( + ['--run_output=false', 'build', '//packages:build', '--show_result=1'], + runOffline + ); }, }; diff --git a/packages/kbn-pm/src/utils/bazel/run.ts b/packages/kbn-pm/src/utils/bazel/run.ts index 34718606db98e..7b20ea43982e6 100644 --- a/packages/kbn-pm/src/utils/bazel/run.ts +++ b/packages/kbn-pm/src/utils/bazel/run.ts @@ -71,5 +71,6 @@ export async function runIBazel( offline: boolean = false, runOpts: execa.Options = {} ) { - await runBazelCommandWithRunner('ibazel', bazelArgs, offline, runOpts); + const extendedEnv = { IBAZEL_USE_LEGACY_WATCHER: '0', ...runOpts?.env }; + await runBazelCommandWithRunner('ibazel', bazelArgs, offline, { ...runOpts, env: extendedEnv }); } diff --git a/packages/kbn-server-http-tools/src/get_server_options.test.ts b/packages/kbn-server-http-tools/src/get_server_options.test.ts index fdcc749f4ae9a..4af9b34dfc5f9 100644 --- a/packages/kbn-server-http-tools/src/get_server_options.test.ts +++ b/packages/kbn-server-http-tools/src/get_server_options.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import moment from 'moment'; import { ByteSizeValue } from '@kbn/config-schema'; import { getServerOptions } from './get_server_options'; import { IHttpConfig } from './types'; @@ -24,6 +25,7 @@ const createConfig = (parts: Partial): IHttpConfig => ({ port: 5601, socketTimeout: 120000, keepaliveTimeout: 120000, + shutdownTimeout: moment.duration(30, 'seconds'), maxPayload: ByteSizeValue.parse('1048576b'), ...parts, cors: { diff --git a/packages/kbn-server-http-tools/src/types.ts b/packages/kbn-server-http-tools/src/types.ts index 3cc117d542eee..9aec520fb3a31 100644 --- a/packages/kbn-server-http-tools/src/types.ts +++ b/packages/kbn-server-http-tools/src/types.ts @@ -7,6 +7,7 @@ */ import { ByteSizeValue } from '@kbn/config-schema'; +import type { Duration } from 'moment'; export interface IHttpConfig { host: string; @@ -16,6 +17,7 @@ export interface IHttpConfig { socketTimeout: number; cors: ICorsConfig; ssl: ISslConfig; + shutdownTimeout: Duration; } export interface ICorsConfig { diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json index a2dc8f84cfb51..2afbe41e0e00e 100644 --- a/packages/kbn-test/package.json +++ b/packages/kbn-test/package.json @@ -19,7 +19,6 @@ "@kbn/optimizer": "link:../kbn-optimizer" }, "devDependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset", "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/expect": "link:../kbn-expect", "@kbn/utils": "link:../kbn-utils" diff --git a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts index bb5b99fdc4439..7f4d0160923bf 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts @@ -115,6 +115,7 @@ export class KbnClientImportExport { excludeExportDetails: true, includeReferencesDeep: true, }, + responseType: 'text', }); if (typeof resp.data !== 'string') { diff --git a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts index 2e1575aee1897..31cd3a6899568 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts @@ -10,7 +10,7 @@ import Url from 'url'; import Https from 'https'; import Qs from 'querystring'; -import Axios, { AxiosResponse } from 'axios'; +import Axios, { AxiosResponse, ResponseType } from 'axios'; import { ToolingLog, isAxiosRequestError, isAxiosResponseError } from '@kbn/dev-utils'; const isConcliftOnGetError = (error: any) => { @@ -53,6 +53,7 @@ export interface ReqOptions { body?: any; retries?: number; headers?: Record; + responseType?: ResponseType; } const delay = (ms: number) => @@ -84,11 +85,16 @@ export class KbnClientRequester { } public resolveUrl(relativeUrl: string = '/') { - return Url.resolve(this.pickUrl(), relativeUrl); + let baseUrl = this.pickUrl(); + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + const relative = relativeUrl.startsWith('/') ? relativeUrl.slice(1) : relativeUrl; + return Url.resolve(baseUrl, relative); } async request(options: ReqOptions): Promise> { - const url = Url.resolve(this.pickUrl(), options.path); + const url = this.resolveUrl(options.path); const description = options.description || `${options.method} ${url}`; let attempt = 0; const maxAttempts = options.retries ?? DEFAULT_MAX_ATTEMPTS; @@ -107,6 +113,9 @@ export class KbnClientRequester { 'kbn-xsrf': 'kbn-client', }, httpsAgent: this.httpsAgent, + responseType: options.responseType, + // work around https://github.com/axios/axios/issues/2791 + transformResponse: options.responseType === 'text' ? [(x) => x] : undefined, paramsSerializer: (params) => Qs.stringify(params), }); diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js index f14c793d22a09..4029ce28faf5b 100644 --- a/packages/kbn-ui-shared-deps/entry.js +++ b/packages/kbn-ui-shared-deps/entry.js @@ -49,3 +49,4 @@ export const TsLib = require('tslib'); export const KbnAnalytics = require('@kbn/analytics'); export const KbnStd = require('@kbn/std'); export const SaferLodashSet = require('@elastic/safer-lodash-set'); +export const RisonNode = require('rison-node'); diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js index 0542bc89ff9e4..62ddb09d25add 100644 --- a/packages/kbn-ui-shared-deps/index.js +++ b/packages/kbn-ui-shared-deps/index.js @@ -60,5 +60,6 @@ exports.externals = { '@kbn/analytics': '__kbnSharedDeps__.KbnAnalytics', '@kbn/std': '__kbnSharedDeps__.KbnStd', '@elastic/safer-lodash-set': '__kbnSharedDeps__.SaferLodashSet', + 'rison-node': '__kbnSharedDeps__.RisonNode', }; exports.publicPathLoader = require.resolve('./public_path_loader'); diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 47a2fa19e7a8e..00c6f677cd223 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -14,7 +14,6 @@ "@kbn/monaco": "link:../kbn-monaco" }, "devDependencies": { - "@kbn/babel-preset": "link:../kbn-babel-preset", "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/rfcs/images/url_service/new_architecture.png b/rfcs/images/url_service/new_architecture.png new file mode 100644 index 0000000000000..9faa025d429bf Binary files /dev/null and b/rfcs/images/url_service/new_architecture.png differ diff --git a/rfcs/images/url_service/old_architecture.png b/rfcs/images/url_service/old_architecture.png new file mode 100644 index 0000000000000..fdb1c13fabf34 Binary files /dev/null and b/rfcs/images/url_service/old_architecture.png differ diff --git a/rfcs/text/0017_url_service.md b/rfcs/text/0017_url_service.md new file mode 100644 index 0000000000000..87a8a92c090d6 --- /dev/null +++ b/rfcs/text/0017_url_service.md @@ -0,0 +1,600 @@ +- Start Date: 2021-03-26 +- RFC PR: (leave this empty) +- Kibana Issue: (leave this empty) + + +# Summary + +Currently in the Kibana `share` plugin we have two services that deal with URLs. + +One is *Short URL Service*: given a long internal Kibana URL it returns an ID. +That ID can be used to "resolve" back to the long URL and redirect the user to +that long URL page. (The Short URL Service is now used in Dashboard, Discover, +Visualize apps, and have a few upcoming users, for example, when sharing panels +by Slack or e-mail we will want to use short URLs.) + +```ts +// It does not have a plugin API, you can only use it through an HTTP request. +const shortUrl = await http.post('/api/shorten_url', { + url: '/some/long/kibana/url/.../very?long=true#q=(rison:approved)' +}); +``` + +The other is the *URL Generator Service*: it simply receives an object of +parameters and returns back a deep link within Kibana. (You can use it, for +example, to navigate to some specific query with specific filters for a +specific index pattern in the Discover app. As of this writing, there are +eight registered URL generators, which are used by ten plugins.) + +```ts +// You first register a URL generator. +const myGenerator = plugins.share.registerUrlGenerator(/* ... */); + +// You can fetch it from the registry (if you don't already have it). +const myGenerator = plugins.share.getUrlGenerator(/* ... */); + +// Now you can use it to generate a deep link into Kibana. +const deepLink: string = myGenerator.createUrl({ /* ... */ }); +``` + + +## Goals of the project + +The proposal is to unify both of these services (Short URL Service and URL +Generator Service) into a single new *URL Service*. The new unified service +will still provide all the functionality the above mentioned services provide +and in addition will implement the following improvements: + +1. Standardize a way for apps to deep link and navigate into other Kibana apps, + with ability to use *location state* to specify the state of the app which is + not part of the URL. +2. Combine Short URL Service with URL Generator Service to allow short URLs to + be constructed from URL generators, which will also allow us to automatically + migrate the short URLs if the parameters of the underlying URL generator + change and be able to store location state in every short URL. +3. Make the short url service easier to use. (It was previously undocumented, + and no server side plugin APIs existed, which meant consumers had to use + REST APIs which is discouraged. Merging the two services will help achieve + this goal by simplifying the APIs.) +4. Support short urls being deleted (previously not possible). +5. Support short urls being migrated (previously not possible). + +See more detailed explanation and other small improvements in the "Motivation" +section below. + + +# Terminology + +In the proposed new service we introduce "locators". This is mostly a change +in language, we are renaming "URL generators" to "locators". The old name would +no longer make sense as we are not returning URLs from locators. + + +# Basic example + +The URL Service will have a client (`UrlServiceClient`) which will have the same +interface, both, on the server-side and the client-side. It will also have a +documented public set of HTTP API endpoints for use by: (1) the client-side +client; (2) external users, Elastic Cloud, and Support. + +The following code examples will work, both, on the server-side and the +client-side, as the base `UrlServiceClient` interface will be similar in both +environments. + +Below we consider four main examples of usage of the URL Service. All four +examples are existing use cases we currently have in Kibana. + + +## Navigating within Kibana using locators + +In this example let's consider a case where Discover app creates a locator, +then another plugin uses that locator to navigate to a deep link within the +Discover app. + +First, the Discover plugin creates its locator (usually one per app). It needs +to do this on the client and server. + + +```ts +const locator = plugins.share.locators.create({ + id: 'DISCOVER_DEEP_LINKS', + getLocation: ({ + indexPattern, + highlightedField, + filters: [], + query: {}, + fields: [], + activeDoc: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx', + }) => { + app: 'discover', + route: `/${indexPatten}#_a=(${risonEncode({filters, query, fields})})`, + state: { + highlightedField, + activeDoc, + }, + }, +}); +``` + +Now, the Discover plugin exports this locator from its plugin contract. + +```ts +class DiscoverPlugin() { + start() { + return { + locator, + }; + } +} +``` + +Finally, if any other app now wants to navigate to a deep link within the +Discover application, they use this exported locator. + +```ts +plugins.discover.locator.navigate({ + indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + highlightedField: 'foo', +}); +``` + +Note, in this example the `highlightedField` parameter will not appear in the +URL bar, it will be passed to the Discover app through [`history.pushState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState) +mechanism (in Kibana case, using the [`history`](https://www.npmjs.com/package/history) package, which is used by `core.application.navigateToApp`). + + +## Sending a deep link to Kibana + +We have use cases were a deep link to some Kibana app is sent out, for example, +through e-mail or as a Slack message. + +In this example, lets consider some plugin gets hold of the Discover locator +on the server-side. + +```ts +const location = plugins.discover.locator.getRedirectPath({ + indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + highlightedField: 'foo', +}); +``` + +This would return the location of the client-side redirect endpoint. The redirect +endpoint could look like this: + +``` +/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x +``` + +This redirect client-side endpoint would find the Discover locator and and +execute the `.navigate()` method on it. + + +## Creating a short link + +In this example, lets create a short link using the Discover locator. + +```ts +const shortUrl = await plugins.discover.locator.createShortUrl( + { + indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + highlightedField: 'foo', + } + 'human-readable-slug', +}); +``` + +The above example creates a short link and persists it in a saved object. The +short URL can have a human-readable slug, which uniquely identifies that short +URL. + +```ts +shortUrl.slug === 'human-readable-slug' +``` + +The short URL can be used to navigate to the Discover app. The redirect +client-side endpoint currently looks like this: + +``` +/app/goto/human-readable-slug +``` + +This persisted short URL would effectively work the same as the full version: + +``` +/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x +``` + + +## External users navigating to a Kibana deep link + +Currently Elastic Cloud and Support have many links linking into Kibana. Most of +them are deep links into Discover and Dashboard apps where, for example, index +pattern is selected, or filters and time range are set. + +The external users could use the above mentioned client-side redirect endpoint +to navigate to their desired deep location within Kibana, for example, to the +Discover application: + +``` +/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x +``` + + +# Motivation + +Our motivation to improve the URL services comes from us intending to use them +more, for example, for panel sharing to Slack or e-mail; and we believe that the +current state of the URL services needs an upgrade. + + +## Limitations of the Short URL Service + +We have identified the following limitations in the current implementation of +the Short URL Service: + +1. There is no migration system. If an application exposes this functionality, + every possible URL that might be generated should be supported forever. A + migration could be written inside the app itself, on page load, but this is a + risky path for URLs with many possibilities. + 1. __Will do:__ Short URLs will be created using locators. We will use + migrations provided by the locators to migrate the stored parameters + in the short URL saved object. +1. Short URLs store only the URL of the destination page. However, the + destination page might have other state which affects the display of the page + but is not present in the URL. Once the short URL is used to navigate to that + page, any state that is kept only in memory is lost. + 1. __Will do:__ The new implementation of the short URLs will also persist + the location state of the URL. That state would be provided to a + Kibana app once a user navigates to that app using a short URL. +1. It exposes only HTTP endpoint API. + 1. __Will do:__ We will also expose a URL Service client through plugin + contract on the server and browser. +1. It only has 3 HTTP endpoints, yet all three have different paths: + (1) `/short_url`, (2) `/shorten_url`; and (3) `/goto`. + 1. __Will do:__ We will normalize the HTTP endpoints. We will use HTTP + method "verbs" like POST, instead of verbs in the url like "shorten_url". +1. There is not much documentation for developers. + 1. __Will do:__ The new service will have a much nicer API and docs. +1. There is no way to delete short URLs once they are created. + 1. __Will do:__ The new service will provide CRUD API to manage short URLs, + including deletion. +1. Short URL service uses MD5 algorithm to hash long URLs. Security team + requested to stop using that algorithm. + 1. __Will do:__ The new URL Service will not use MD5 algorithm. +1. Short URLs are not automatically deleted when the target (say dashboard) is + deleted. (#10450) + 1. __Could do:__ The URL Service will not provide such feature. Though the + short URLs will keep track of saved object references used in the params + to generate a short URL. Maybe those saved references could somehow be + used in the future to provide such a facility. + + Currently, there are two possible avenues for deleting a short URL when + the underlying dashboard is deleted: + + 1. The Dashboard app could keep track of short URLs it generates for each + dashboard. Once a dashboard is deleted, the Dashboard app also + deletes all short URLs associated with that dashboard. + 1. Saved Objects Service could implement *cascading deletes*. Once a saved + object is deleted, the associated saved objects are also deleted + (#71453). +1. Add additional metadata to each short URL. + 1. __Could do:__ Each short URL already keeps a counter of how often it was + resolved, we could also keep track of a timestamp when it was last + resolved, and have an ability for users to give a title to each short URL. +1. Short URLs don't have a management UI. + 1. __Will NOT do:__ We will not create a dedicated UI for managing short + URLs. We could improve how short URLs saved objects are presented in saved + object management UI. +1. Short URLs can't be created by read-only users (#18006). + 1. __Will NOT do:__ Currently short URLs are stored as saved objects of type + `url`, we would like to keep it that way and benefit from saved object + facilities like references, migrations, authorization etc.. The consensus + is that we will not allow anonymous users to create short URLs. We want to + continue using saved object for short URLs going forward and not + compromise on their security model. + + +## Limitations of the URL Generator Service + +We have identified the following limitations in the current implementation of +the URL Generator Service: + +1. URL generator generate only the URL of the destination. However there is + also the ability to use location state with `core.application.navigateToApp` + navigation method. + 1. __Will do:__ The new locators will also generate the location state, which + will be used in `.navigateToApp` method. +1. URL generators are available only on the client-side. There is no way to use + them together with short URLs. + 1. __Will do:__ We will implement locators also on the server-side + (they will be available in both environments) and we will combine them + with the Short URL Service. +1. URL generators are not exposed externally, thus Cloud and Support cannot use + them to generate deep links into Kibana. + 1. __Will do:__ We will expose HTTP endpoints on the server-side and the + "redirect" app on the client-side which external users will be able to use + to deep link into Kibana using locators. + + +## Limitations of the architecture + +One major reason we want to "refresh" the Short URL Service and the URL +Generator Service is their architecture. + +Currently, the Short URL Service is implemented on top of the `url` type saved +object on the server-side. However, it only exposes the +HTTP endpoints, it does not expose any API on the server for the server-side +plugins to consume; on the client-side there is no plugin API either, developers +need to manually execute HTTP requests. + +The URL Generator Service is only available on the client-side, there is no way +to use it on the server-side, yet we already have use cases (for example ML +team) where a server-side plugin wants to use a URL generator. + +![Current Short URL Service and URL Generator Service architecture](../images/url_service/old_architecture.png) + +The current architecture does not allow both services to be conveniently used, +also as they are implemented in different locations, they are disjointed— +we cannot create a short URL using an URL generator. + + +# Detailed design + +In general we will try to provide as much as possible the same API on the +server-side and the client-side. + + +## High level architecture + +Below diagram shows the proposed architecture of the URL Service. + +![URL Service architecture](../images/url_service/new_architecture.png) + + +## Plugin contracts + +The aim is to provide developers the same experience on the server and browser. + +Below are preliminary interfaces of the new URL Service. `IUrlService` will be +a shared interface defined in `/common` folder shared across server and browser. +This will allow us to provide users a common API interface on the server and +browser, wherever they choose to use the URL Service: + +```ts +/** + * Common URL Service client interface for the server-side and the client-side. + */ +interface IUrlService { + locators: ILocatorClient; + shortUrls: IShortUrlClient; +} +``` + + +### Locators + +The locator business logic will be contained in `ILocatorClient` client and will +provide two main functionalities: + +1. It will provide a facility to create locators. +1. It will also be a registry of locators, every newly created locator is + automatically added to the registry. The registry should never be used when + locator ID is known at the compile time, but is reserved only for use cases + when we only know ID of a locator at runtime. + +```ts +interface ILocatorClient { + create

(definition: LocatorDefinition

): Locator

; + get

(id: string): Locator

; +} +``` + +The `LocatorDefinition` interface is a developer-friendly interface for creating +new locators. Mainly two things will be required from each new locator: + +1. Implement the `getLocation()` method, which gives the locator specific `params` + object returns a Kibana location, see description of `KibanaLocation` below. +2. Implement the `PersistableState` interface which we use in Kibana. This will + allow to migrate the locator `params`. Implementation of the `PersistableState` + interface will replace the `.isDeprecated` and `.migrate()` properties of URL + generators. + + +```ts +interface LocatorDefinition

extends PeristableState

{ + id: string; + getLocation(params: P): KibanaLocation; +} +``` + +Each constructed locator will have the following interface: + +```ts +interface Locator

{ + /** Creates a new short URL saved object using this locator. */ + createShortUrl(params: P, slug?: string): Promise; + /** Returns a relative URL to the client-side redirect endpoint using this locator. */ + getRedirectPath(params: P): string; + /** Navigate using core.application.navigateToApp() using this locator. */ + navigate(params: P): void; // Only on browser. +} +``` + + +### Short URLs + +The short URL client `IShortUrlClient` which will be the same on the server and +browser. However, the server and browser might add extra utility methods for +convenience. + +```ts +/** + * CRUD-like API for short URLs. + */ +interface IShortUrlClient { + /** + * Delete a short URL. + * + * @param slug The slug (ID) of the short URL. + * @return Returns true if deletion was successful. + */ + delete(slug: string): Promise; + + /** + * Fetch short URL. + * + * @param slug The slug (ID) of the short URL. + */ + get(slug: string): Promise; + + /** + * Same as `get()` but it also increments the "view" counter and the + * "last view" timestamp of this short URL. + * + * @param slug The slug (ID) of the short URL. + */ + resolve(slug: string): Promise; +} +``` + +Note, that in this new service to create a short URL the developer will have to +use a locator (instead of creating it directly from a long URL). + +```ts +const shortUrl = await plugins.share.shortUrls.create( + plugins.discover.locator, + { + indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + highlightedField: 'foo', + }, + 'optional-human-readable-slug', +); +``` + +These short URLs will be stored in saved objects of type `url` and will be +automatically migrated using the locator. The long URL will NOT be stored in the +saved object. The locator ID and locator params will be stored in the saved +object, that will allow us to do the migrations for short URLs. + + +### `KibanaLocation` interface + +The `KibanaLocation` interface is a simple interface to store a location in some +Kibana application. + +```ts +interface KibanaLocation { + app: string; + route: string; + state: object; +} +``` + +It maps directly to a `.navigateToApp()` call. + +```ts +let location: KibanaLocation; + +core.application.navigateToApp(location.app, { + route: location.route, + state: location.state, +}); +``` + + +## HTTP endpoints + + +### Short URL CRUD+ HTTP endpoints + +Below HTTP endpoints are designed to work specifically with short URLs: + +| HTTP method | Path | Description | +|-----------------------|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| __POST__ | `/api/short_url` | Endpoint for creating new short URLs. | +| __GET__ | `/api/short_url/` | Endpoint for retrieving information about an existing short URL. | +| __DELETE__ | `/api/short_url/` | Endpoint for deleting an existing short URL. | +| __POST__ | `/api/short_url/` | Endpoint for updating information about an existing short URL. | +| __POST__ | `/api/short_url//_resolve` | Similar to `GET /api/short_url/`, but also increments the short URL access count counter and the last access timestamp. | + + +### The client-side navigate endpoint + +__NOTE.__ We are currently investigating if we really need this endpoint. The +main user of it was expected to be Cloud and Support to deeply link into Kibana, +but we are now reconsidering if we want to support this endpoint and possibly +find a different solution. + +The `/app/goto/_redirect/?params=...¶msVersion=...` client-side +endpoint will receive the locator ID and locator params, it will use those to +find the locator and execute `locator.navigate(params)` method. + +The `paramsVersion` parameter will be used to specify the version of the +`params` parameter. If the version is behind the latest version, then the migration +facilities of the locator will be used to on-the-fly migrate the `params` to the +latest version. + + +### Legacy endpoints + +Below are the legacy HTTP endpoints implemented by the `share` plugin, with a +plan of action for each endpoint: + +| HTTP method | Path | Description | +|-----------------------|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| __ANY__ | `/goto/` | Endpoint for redirecting short URLs, we will keep it to redirect short URLs. | +| __GET__ | `/api/short_url/` | The new `GET /api/short_url/` endpoint will return a superset of the payload that the legacy endpoint now returns. | +| __POST__ | `/api/shorten_url` | The legacy endpoints for creating short URLs. We will remove it or deprecate this endpoint and maintain it until 8.0 major release. | + + +# Drawbacks + +Why should we *not* do this? + +- Implementation cost will be a few weeks, but the code complexity and quality + will improve. +- There is a cost of migrating existing Kibana plugins to use the new API. + + +# Alternatives + +We haven't considered other design alternatives. + +One alternative is still do the short URL improvements outlined above. But +reconsider URL generators: + +- Do we need URL generators at all? + - Kibana URLs are not stable and have changed in our past experience. Hence, + the URL generators were created to make the URL generator parameters stable + unless a migration is available. +- Do we want to put migration support in URL generators? + - Alternative would be for each app to support URLs forever or do the + migrations on the fly for old URLs. +- Should Kibana URLs be stable and break only during major releases? +- Should the Kibana application interface be extended such that some version of + URL generators is built in? + +The impact of not doing this change is essentially extending technical debt. + + +# Adoption strategy + +Is this a breaking change? It is a breaking change in the sense that the API +will change. However, all the existing use cases will be supported. When +implementing this we will also adjust all Kibana code to use the new API. From +the perspective of the developers when using the existing URL services nothing +will change, they will simply need to review a PR which stops using the URL +Generator Service and uses the combined URL Service instead, which will provide +a superset of features. + +Alternatively, we can deprecate the URL Generator Service and maintain it for a +few minor releases. + + +# How we teach this + +For the existing short URL and URL generator functionality there is nothing to +teach, as they will continue working with a largely similar API. + +Everything else in the new URL Service will have JSDoc comments and good +documentation on our website. diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 4220d3e490f63..0ecfc152197d3 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -21,12 +21,16 @@ export class DocLinksService { const DOC_LINK_VERSION = injectedMetadata.getKibanaBranch(); const ELASTIC_WEBSITE_URL = 'https://www.elastic.co/'; const ELASTICSEARCH_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`; + const KIBANA_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/`; const PLUGIN_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`; return deepFreeze({ DOC_LINK_VERSION, ELASTIC_WEBSITE_URL, links: { + canvas: { + guide: `${KIBANA_DOCS}canvas.html`, + }, dashboard: { guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/dashboard.html`, drilldowns: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/drilldowns.html`, @@ -245,10 +249,10 @@ export class DocLinksService { guide: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/index.html`, }, alerting: { - guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/managing-alerts-and-actions.html`, + guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-management.html`, actionTypes: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/action-types.html`, emailAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html`, - emailActionConfig: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html#configuring-email`, + emailActionConfig: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html`, generalSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`, indexAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index-action-type.html`, esQuery: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/rule-type-es-query.html`, @@ -397,6 +401,9 @@ export interface DocLinksStart { readonly DOC_LINK_VERSION: string; readonly ELASTIC_WEBSITE_URL: string; readonly links: { + readonly canvas: { + readonly guide: string; + }; readonly dashboard: { readonly guide: string; readonly drilldowns: string; diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index d0374511515d1..801fa452e8332 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -6,27 +6,57 @@ exports[`#start() returns \`Context\` component 1`] = ` i18n={ Object { "mapping": Object { + "euiAccordion.isLoading": "Loading", "euiBasicTable.selectAllRows": "Select all rows", "euiBasicTable.selectThisRow": "Select this row", - "euiBasicTable.tableDescription": [Function], - "euiBottomBar.screenReaderAnnouncement": "There is a new menu opening with page level controls at the end of the document.", - "euiBreadcrumbs.collapsedBadge.ariaLabel": "Show all breadcrumbs", + "euiBasicTable.tableAutoCaptionWithPagination": [Function], + "euiBasicTable.tableAutoCaptionWithoutPagination": [Function], + "euiBasicTable.tableCaptionWithPagination": [Function], + "euiBasicTable.tablePagination": [Function], + "euiBasicTable.tableSimpleAutoCaptionWithPagination": [Function], + "euiBottomBar.customScreenReaderAnnouncement": [Function], + "euiBottomBar.screenReaderAnnouncement": "There is a new region landmark with page level controls at the end of the document.", + "euiBottomBar.screenReaderHeading": "Page level controls", + "euiBreadcrumbs.collapsedBadge.ariaLabel": "Show collapsed breadcrumbs", "euiCardSelect.select": "Select", "euiCardSelect.selected": "Selected", "euiCardSelect.unavailable": "Unavailable", "euiCodeBlock.copyButton": "Copy", + "euiCodeBlock.fullscreenCollapse": "Collapse", + "euiCodeBlock.fullscreenExpand": "Expand", "euiCodeEditor.startEditing": "Press Enter to start editing.", "euiCodeEditor.startInteracting": "Press Enter to start interacting with the code.", "euiCodeEditor.stopEditing": "When you're done, press Escape to stop editing.", "euiCodeEditor.stopInteracting": "When you're done, press Escape to stop interacting with the code.", "euiCollapsedItemActions.allActions": "All actions", + "euiCollapsibleNav.closeButtonLabel": "close", + "euiColorPicker.alphaLabel": "Alpha channel (opacity) value", + "euiColorPicker.closeLabel": "Press the down key to open a popover containing color options", + "euiColorPicker.colorErrorMessage": "Invalid color value", + "euiColorPicker.colorLabel": "Color value", + "euiColorPicker.openLabel": "Press the escape key to close the popover", "euiColorPicker.screenReaderAnnouncement": "A popup with a range of selectable colors opened. Tab forward to cycle through colors choices or press escape to close this popup.", "euiColorPicker.swatchAriaLabel": [Function], + "euiColorPicker.transparent": "Transparent", + "euiColorStopThumb.buttonAriaLabel": "Press the Enter key to modify this stop. Press Escape to focus the group", + "euiColorStopThumb.buttonTitle": "Click to edit, drag to reposition", "euiColorStopThumb.removeLabel": "Remove this stop", "euiColorStopThumb.screenReaderAnnouncement": "A popup with a color stop edit form opened. Tab forward to cycle through form controls or press escape to close this popup.", + "euiColorStopThumb.stopErrorMessage": "Value is out of range", + "euiColorStopThumb.stopLabel": "Stop value", "euiColorStops.screenReaderAnnouncement": [Function], + "euiColumnActions.moveLeft": "Move left", + "euiColumnActions.moveRight": "Move right", + "euiColumnActions.sort": [Function], + "euiColumnSelector.button": "Columns", + "euiColumnSelector.buttonActivePlural": [Function], + "euiColumnSelector.buttonActiveSingular": [Function], "euiColumnSelector.hideAll": "Hide all", + "euiColumnSelector.search": "Search", + "euiColumnSelector.searchcolumns": "Search columns", "euiColumnSelector.selectAll": "Show all", + "euiColumnSorting.button": "Sort fields", + "euiColumnSorting.buttonActive": "fields sorted", "euiColumnSorting.clearAll": "Clear sorting", "euiColumnSorting.emptySorting": "Currently no fields are sorted", "euiColumnSorting.pickFields": "Pick fields to sort by", @@ -39,15 +69,25 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiComboBoxOptionsList.allOptionsSelected": "You've selected all available options", "euiComboBoxOptionsList.alreadyAdded": [Function], "euiComboBoxOptionsList.createCustomOption": [Function], + "euiComboBoxOptionsList.delimiterMessage": [Function], "euiComboBoxOptionsList.loadingOptions": "Loading options", "euiComboBoxOptionsList.noAvailableOptions": "There aren't any options available", "euiComboBoxOptionsList.noMatchingOptions": [Function], "euiComboBoxPill.removeSelection": [Function], "euiCommonlyUsedTimeRanges.legend": "Commonly used", + "euiDataGrid.ariaLabel": [Function], + "euiDataGrid.ariaLabelGridPagination": [Function], + "euiDataGrid.ariaLabelledBy": [Function], + "euiDataGrid.ariaLabelledByGridPagination": "Pagination for preceding grid", + "euiDataGrid.fullScreenButton": "Full screen", + "euiDataGrid.fullScreenButtonActive": "Exit full screen", "euiDataGrid.screenReaderNotice": "Cell contains interactive content.", - "euiDataGridCell.expandButtonTitle": "Click or hit enter to interact with cell content", - "euiDataGridSchema.booleanSortTextAsc": "True-False", - "euiDataGridSchema.booleanSortTextDesc": "False-True", + "euiDataGridCell.column": "Column", + "euiDataGridCell.row": "Row", + "euiDataGridCellButtons.expandButtonTitle": "Click or hit enter to interact with cell content", + "euiDataGridHeaderCell.headerActions": "Header actions", + "euiDataGridSchema.booleanSortTextAsc": "False-True", + "euiDataGridSchema.booleanSortTextDesc": "True-False", "euiDataGridSchema.currencySortTextAsc": "Low-High", "euiDataGridSchema.currencySortTextDesc": "High-Low", "euiDataGridSchema.dateSortTextAsc": "New-Old", @@ -56,22 +96,56 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiDataGridSchema.jsonSortTextDesc": "Large-Small", "euiDataGridSchema.numberSortTextAsc": "Low-High", "euiDataGridSchema.numberSortTextDesc": "High-Low", + "euiFieldPassword.maskPassword": "Mask password", + "euiFieldPassword.showPassword": "Show password as plain text. Note: this will visually expose your password on the screen.", + "euiFilePicker.clearSelectedFiles": "Clear selected files", + "euiFilePicker.filesSelected": "files selected", "euiFilterButton.filterBadge": [Function], - "euiForm.addressFormErrors": "Please address the errors in your form.", + "euiFlyout.closeAriaLabel": "Close this dialog", + "euiForm.addressFormErrors": "Please address the highlighted errors.", "euiFormControlLayoutClearButton.label": "Clear input", "euiHeaderAlert.dismiss": "Dismiss", - "euiHeaderLinks.appNavigation": "App navigation", - "euiHeaderLinks.openNavigationMenu": "Open navigation menu", + "euiHeaderLinks.appNavigation": "App menu", + "euiHeaderLinks.openNavigationMenu": "Open menu", "euiHue.label": "Select the HSV color mode \\"hue\\" value", "euiImage.closeImage": [Function], "euiImage.openImage": [Function], "euiLink.external.ariaLabel": "External link", + "euiLink.newTarget.screenReaderOnlyText": "(opens in a new tab or window)", + "euiMarkdownEditorFooter.closeButton": "Close", + "euiMarkdownEditorFooter.descriptionPrefix": "This editor uses", + "euiMarkdownEditorFooter.descriptionSuffix": "You can also utilize these additional syntax plugins to add rich content to your text.", + "euiMarkdownEditorFooter.errorsTitle": "Errors", + "euiMarkdownEditorFooter.openUploadModal": "Open upload files modal", + "euiMarkdownEditorFooter.showMarkdownHelp": "Show markdown help", + "euiMarkdownEditorFooter.showSyntaxErrors": "Show errors", + "euiMarkdownEditorFooter.supportedFileTypes": [Function], + "euiMarkdownEditorFooter.syntaxTitle": "Syntax help", + "euiMarkdownEditorFooter.unsupportedFileType": "File type not supported", + "euiMarkdownEditorFooter.uploadingFiles": "Click to upload files", + "euiMarkdownEditorToolbar.editor": "Editor", + "euiMarkdownEditorToolbar.previewMarkdown": "Preview", "euiModal.closeModal": "Closes this modal window", - "euiPagination.jumpToLastPage": [Function], - "euiPagination.nextPage": "Next page", - "euiPagination.pageOfTotal": [Function], - "euiPagination.previousPage": "Previous page", + "euiNotificationEventMessages.accordionAriaLabelButtonText": [Function], + "euiNotificationEventMessages.accordionButtonText": [Function], + "euiNotificationEventMessages.accordionHideText": "hide", + "euiNotificationEventMeta.contextMenuButton": [Function], + "euiNotificationEventReadButton.markAsRead": "Mark as read", + "euiNotificationEventReadButton.markAsReadAria": [Function], + "euiNotificationEventReadButton.markAsUnread": "Mark as unread", + "euiNotificationEventReadButton.markAsUnreadAria": [Function], + "euiPagination.disabledNextPage": "Next page", + "euiPagination.disabledPreviousPage": "Previous page", + "euiPagination.firstRangeAriaLabel": [Function], + "euiPagination.lastRangeAriaLabel": [Function], + "euiPagination.nextPage": [Function], + "euiPagination.previousPage": [Function], + "euiPaginationButton.longPageString": [Function], + "euiPaginationButton.shortPageString": [Function], + "euiPinnableListGroup.pinExtraActionLabel": "Pin item", + "euiPinnableListGroup.pinnedExtraActionLabel": "Unpin item", "euiPopover.screenReaderAnnouncement": "You are in a dialog. To close this dialog, hit escape.", + "euiProgress.valueText": [Function], "euiQuickSelect.applyButton": "Apply", "euiQuickSelect.fullDescription": [Function], "euiQuickSelect.legendText": "Quick select a time range", @@ -81,27 +155,54 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiQuickSelect.tenseLabel": "Time tense", "euiQuickSelect.unitLabel": "Time unit", "euiQuickSelect.valueLabel": "Time value", + "euiRecentlyUsed.legend": "Recently used date ranges", "euiRefreshInterval.fullDescription": [Function], "euiRefreshInterval.legend": "Refresh every", "euiRefreshInterval.start": "Start", "euiRefreshInterval.stop": "Stop", "euiRelativeTab.fullDescription": [Function], + "euiRelativeTab.numberInputError": "Must be >= 0", + "euiRelativeTab.numberInputLabel": "Time span amount", "euiRelativeTab.relativeDate": [Function], "euiRelativeTab.roundingLabel": [Function], "euiRelativeTab.unitInputLabel": "Relative time span", + "euiResizableButton.horizontalResizerAriaLabel": "Press left or right to adjust panels size", + "euiResizableButton.verticalResizerAriaLabel": "Press up or down to adjust panels size", + "euiResizablePanel.toggleButtonAriaLabel": "Press to toggle this panel", "euiSaturation.roleDescription": "HSV color mode saturation and value selection", "euiSaturation.screenReaderAnnouncement": "Use the arrow keys to navigate the square color gradient. The coordinates resulting from each key press will be used to calculate HSV color mode \\"saturation\\" and \\"value\\" numbers, in the range of 0 to 1. Left and right decrease and increase (respectively) the \\"saturation\\" value. Up and down decrease and increase (respectively) the \\"value\\" value.", "euiSelectable.loadingOptions": "Loading options", "euiSelectable.noAvailableOptions": "There aren't any options available", "euiSelectable.noMatchingOptions": [Function], + "euiSelectable.placeholderName": "Filter options", + "euiSelectableListItem.excludedOption": "Excluded option.", + "euiSelectableListItem.excludedOptionInstructions": "To deselect this option, press enter", + "euiSelectableListItem.includedOption": "Included option.", + "euiSelectableListItem.includedOptionInstructions": "To exclude this option, press enter.", + "euiSelectableTemplateSitewide.loadingResults": "Loading results", + "euiSelectableTemplateSitewide.noResults": "No results available", + "euiSelectableTemplateSitewide.onFocusBadgeGoTo": "Go to", + "euiSelectableTemplateSitewide.searchPlaceholder": "Search for anything...", "euiStat.loadingText": "Statistic is loading", - "euiStep.ariaLabel": [Function], - "euiStepHorizontal.buttonTitle": [Function], - "euiStepHorizontal.step": "Step", - "euiStepNumber.hasErrors": "has errors", - "euiStepNumber.hasWarnings": "has warnings", - "euiStepNumber.isComplete": "complete", + "euiStepStrings.complete": [Function], + "euiStepStrings.disabled": [Function], + "euiStepStrings.errors": [Function], + "euiStepStrings.incomplete": [Function], + "euiStepStrings.loading": [Function], + "euiStepStrings.simpleComplete": [Function], + "euiStepStrings.simpleDisabled": [Function], + "euiStepStrings.simpleErrors": [Function], + "euiStepStrings.simpleIncomplete": [Function], + "euiStepStrings.simpleLoading": [Function], + "euiStepStrings.simpleStep": [Function], + "euiStepStrings.simpleWarning": [Function], + "euiStepStrings.step": [Function], + "euiStepStrings.warning": [Function], + "euiStyleSelector.buttonLegend": "Select the display density for the data grid", "euiStyleSelector.buttonText": "Density", + "euiStyleSelector.labelCompact": "Compact density", + "euiStyleSelector.labelExpanded": "Expanded density", + "euiStyleSelector.labelNormal": "Normal density", "euiSuperDatePicker.showDatesButtonLabel": "Show dates", "euiSuperSelect.screenReaderAnnouncement": [Function], "euiSuperSelectControl.selectAnOption": [Function], @@ -110,12 +211,23 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiSuperUpdateButton.refreshButtonLabel": "Refresh", "euiSuperUpdateButton.updateButtonLabel": "Update", "euiSuperUpdateButton.updatingButtonLabel": "Updating", + "euiTableHeaderCell.clickForAscending": "Click to sort in ascending order", + "euiTableHeaderCell.clickForDescending": "Click to sort in descending order", + "euiTableHeaderCell.clickForUnsort": "Click to unsort", + "euiTableHeaderCell.titleTextWithSort": [Function], "euiTablePagination.rowsPerPage": "Rows per page", "euiTablePagination.rowsPerPageOption": [Function], "euiTableSortMobile.sorting": "Sorting", "euiToast.dismissToast": "Dismiss toast", "euiToast.newNotification": "A new notification appears", "euiToast.notification": "Notification", + "euiTour.closeTour": "Close tour", + "euiTour.endTour": "End tour", + "euiTour.skipTour": "Skip tour", + "euiTourStepIndicator.ariaLabel": [Function], + "euiTourStepIndicator.isActive": "active", + "euiTourStepIndicator.isComplete": "complete", + "euiTourStepIndicator.isIncomplete": "incomplete", "euiTreeView.ariaLabel": [Function], "euiTreeView.listNavigationInstructions": "You can quickly navigate this list using arrow keys.", }, diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 1ef033289e542..1cccc4d94a78d 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -16,6 +16,9 @@ interface EuiValues { export const getEuiContextMapping = () => { const euiContextMapping = { + 'euiAccordion.isLoading': i18n.translate('core.euiAccordion.isLoading', { + defaultMessage: 'Loading', + }), 'euiBasicTable.selectAllRows': i18n.translate('core.euiBasicTable.selectAllRows', { defaultMessage: 'Select all rows', description: 'ARIA and displayed label on a checkbox to select all table rows', @@ -24,25 +27,71 @@ export const getEuiContextMapping = () => { defaultMessage: 'Select this row', description: 'ARIA and displayed label on a checkbox to select a single table row', }), - 'euiBasicTable.tableDescription': ({ itemCount }: EuiValues) => - i18n.translate('core.euiBasicTable.tableDescription', { - defaultMessage: 'Below is a table of {itemCount} items.', + 'euiBasicTable.tableCaptionWithPagination': ({ tableCaption, page, pageCount }: EuiValues) => + i18n.translate('core.euiBasicTable.tableCaptionWithPagination', { + defaultMessage: '{tableCaption}; Page {page} of {pageCount}.', + values: { tableCaption, page, pageCount }, + description: 'Screen reader text to describe the size of a paginated table', + }), + 'euiBasicTable.tableAutoCaptionWithPagination': ({ + itemCount, + totalItemCount, + page, + pageCount, + }: EuiValues) => + i18n.translate('core.euiBasicTable.tableDescriptionWithoutPagination', { + defaultMessage: + 'This table contains {itemCount} rows out of {totalItemCount} rows; Page {page} of {pageCount}.', + values: { itemCount, totalItemCount, page, pageCount }, + description: 'Screen reader text to describe the size of a paginated table', + }), + 'euiBasicTable.tableSimpleAutoCaptionWithPagination': ({ + itemCount, + page, + pageCount, + }: EuiValues) => + i18n.translate('core.euiBasicTable.tableSimpleAutoCaptionWithPagination', { + defaultMessage: 'This table contains {itemCount} rows; Page {page} of {pageCount}.', + values: { itemCount, page, pageCount }, + description: 'Screen reader text to describe the size of a paginated table', + }), + 'euiBasicTable.tableAutoCaptionWithoutPagination': ({ itemCount }: EuiValues) => + i18n.translate('core.euiBasicTable.tableAutoCaptionWithoutPagination', { + defaultMessage: 'This table contains {itemCount} rows.', values: { itemCount }, description: 'Screen reader text to describe the size of a table', }), + 'euiBasicTable.tablePagination': ({ tableCaption }: EuiValues) => + i18n.translate('core.euiBasicTable.tablePagination', { + defaultMessage: 'Pagination for preceding table: {tableCaption}', + values: { tableCaption }, + description: 'Screen reader text to describe the pagination controls', + }), + 'euiBottomBar.customScreenReaderAnnouncement': ({ landmarkHeading }: EuiValues) => + i18n.translate('core.euiBottomBar.customScreenReaderAnnouncement', { + defaultMessage: + 'There is a new region landmark called {landmarkHeading} with page level controls at the end of the document.', + values: { landmarkHeading }, + description: + 'Screen reader announcement that functionality is available in the page document', + }), 'euiBottomBar.screenReaderAnnouncement': i18n.translate( 'core.euiBottomBar.screenReaderAnnouncement', { defaultMessage: - 'There is a new menu opening with page level controls at the end of the document.', + 'There is a new region landmark with page level controls at the end of the document.', description: 'Screen reader announcement that functionality is available in the page document', } ), + 'euiBottomBar.screenReaderHeading': i18n.translate('core.euiBottomBar.screenReaderHeading', { + defaultMessage: 'Page level controls', + description: 'Screen reader announcement about heading controls', + }), 'euiBreadcrumbs.collapsedBadge.ariaLabel': i18n.translate( 'core.euiBreadcrumbs.collapsedBadge.ariaLabel', { - defaultMessage: 'Show all breadcrumbs', + defaultMessage: 'Show collapsed breadcrumbs', description: 'Displayed when one or more breadcrumbs are hidden.', } ), @@ -62,17 +111,29 @@ export const getEuiContextMapping = () => { defaultMessage: 'Copy', description: 'ARIA label for a button that copies source code text to the clipboard', }), + 'euiCodeBlock.fullscreenCollapse': i18n.translate('core.euiCodeBlock.fullscreenCollapse', { + defaultMessage: 'Collapse', + description: 'ARIA label for a button that exits fullscreen view', + }), + 'euiCodeBlock.fullscreenExpand': i18n.translate('core.euiCodeBlock.fullscreenExpand', { + defaultMessage: 'Expand', + description: 'ARIA label for a button that enters fullscreen view', + }), 'euiCodeEditor.startEditing': i18n.translate('core.euiCodeEditor.startEditing', { defaultMessage: 'Press Enter to start editing.', + description: 'Screen reader text to prompt editing', }), 'euiCodeEditor.startInteracting': i18n.translate('core.euiCodeEditor.startInteracting', { defaultMessage: 'Press Enter to start interacting with the code.', + description: 'Screen reader text to prompt interaction', }), 'euiCodeEditor.stopEditing': i18n.translate('core.euiCodeEditor.stopEditing', { defaultMessage: "When you're done, press Escape to stop editing.", + description: 'Screen reader text to describe ending editing', }), 'euiCodeEditor.stopInteracting': i18n.translate('core.euiCodeEditor.stopInteracting', { defaultMessage: "When you're done, press Escape to stop interacting with the code.", + description: 'Screen reader text to describe ending interactions', }), 'euiCollapsedItemActions.allActions': i18n.translate( 'core.euiCollapsedItemActions.allActions', @@ -82,6 +143,12 @@ export const getEuiContextMapping = () => { 'ARIA label and tooltip content describing a button that expands an actions menu', } ), + 'euiCollapsibleNav.closeButtonLabel': i18n.translate( + 'core.euiCollapsibleNav.closeButtonLabel', + { + defaultMessage: 'close', + } + ), 'euiColorPicker.screenReaderAnnouncement': i18n.translate( 'core.euiColorPicker.screenReaderAnnouncement', { @@ -98,6 +165,27 @@ export const getEuiContextMapping = () => { description: 'Screen reader text to describe the action and hex value of the selectable option', }), + 'euiColorPicker.alphaLabel': i18n.translate('core.euiColorPicker.alphaLabel', { + defaultMessage: 'Alpha channel (opacity) value', + description: 'Label describing color alpha channel', + }), + 'euiColorPicker.colorLabel': i18n.translate('core.euiColorPicker.colorLabel', { + defaultMessage: 'Color value', + }), + 'euiColorPicker.colorErrorMessage': i18n.translate('core.euiColorPicker.colorErrorMessage', { + defaultMessage: 'Invalid color value', + }), + 'euiColorPicker.transparent': i18n.translate('core.euiColorPicker.transparent', { + defaultMessage: 'Transparent', + }), + 'euiColorPicker.openLabel': i18n.translate('core.euiColorPicker.openLabel', { + defaultMessage: 'Press the escape key to close the popover', + description: 'Screen reader text to describe how to close the picker', + }), + 'euiColorPicker.closeLabel': i18n.translate('core.euiColorPicker.closeLabel', { + defaultMessage: 'Press the down key to open a popover containing color options', + description: 'Screen reader text to describe how to open the picker', + }), 'euiColorStopThumb.removeLabel': i18n.translate('core.euiColorStopThumb.removeLabel', { defaultMessage: 'Remove this stop', description: 'Label accompanying a button whose action will remove the color stop', @@ -111,6 +199,23 @@ export const getEuiContextMapping = () => { 'Message when the color picker popover has opened for an individual color stop thumb.', } ), + 'euiColorStopThumb.buttonAriaLabel': i18n.translate('core.euiColorStopThumb.buttonAriaLabel', { + defaultMessage: 'Press the Enter key to modify this stop. Press Escape to focus the group', + description: 'Screen reader text to describe picker interaction', + }), + 'euiColorStopThumb.buttonTitle': i18n.translate('core.euiColorStopThumb.buttonTitle', { + defaultMessage: 'Click to edit, drag to reposition', + description: 'Screen reader text to describe button interaction', + }), + 'euiColorStopThumb.stopLabel': i18n.translate('core.euiColorStopThumb.stopLabel', { + defaultMessage: 'Stop value', + }), + 'euiColorStopThumb.stopErrorMessage': i18n.translate( + 'core.euiColorStopThumb.stopErrorMessage', + { + defaultMessage: 'Value is out of range', + } + ), 'euiColorStops.screenReaderAnnouncement': ({ label, readOnly, disabled }: EuiValues) => i18n.translate('core.euiColorStops.screenReaderAnnouncement', { defaultMessage: @@ -119,12 +224,42 @@ export const getEuiContextMapping = () => { description: 'Screen reader text to describe the composite behavior of the color stops component.', }), + 'euiColumnActions.sort': ({ schemaLabel }: EuiValues) => + i18n.translate('core.euiColumnActions.sort', { + defaultMessage: 'Sort {schemaLabel}', + values: { schemaLabel }, + }), + 'euiColumnActions.moveLeft': i18n.translate('core.euiColumnActions.moveLeft', { + defaultMessage: 'Move left', + }), + 'euiColumnActions.moveRight': i18n.translate('core.euiColumnActions.moveRight', { + defaultMessage: 'Move right', + }), 'euiColumnSelector.hideAll': i18n.translate('core.euiColumnSelector.hideAll', { defaultMessage: 'Hide all', }), 'euiColumnSelector.selectAll': i18n.translate('core.euiColumnSelector.selectAll', { defaultMessage: 'Show all', }), + 'euiColumnSelector.button': i18n.translate('core.euiColumnSelector.button', { + defaultMessage: 'Columns', + }), + 'euiColumnSelector.search': i18n.translate('core.euiColumnSelector.search', { + defaultMessage: 'Search', + }), + 'euiColumnSelector.searchcolumns': i18n.translate('core.euiColumnSelector.searchcolumns', { + defaultMessage: 'Search columns', + }), + 'euiColumnSelector.buttonActiveSingular': ({ numberOfHiddenFields }: EuiValues) => + i18n.translate('core.euiColumnSelector.buttonActiveSingular', { + defaultMessage: '{numberOfHiddenFields} column hidden', + values: { numberOfHiddenFields }, + }), + 'euiColumnSelector.buttonActivePlural': ({ numberOfHiddenFields }: EuiValues) => + i18n.translate('core.euiColumnSelector.buttonActivePlural', { + defaultMessage: '{numberOfHiddenFields} columns hidden', + values: { numberOfHiddenFields }, + }), 'euiColumnSorting.clearAll': i18n.translate('core.euiColumnSorting.clearAll', { defaultMessage: 'Clear sorting', }), @@ -140,6 +275,12 @@ export const getEuiContextMapping = () => { defaultMessage: 'Sort by:', } ), + 'euiColumnSorting.button': i18n.translate('core.euiColumnSorting.button', { + defaultMessage: 'Sort fields', + }), + 'euiColumnSorting.buttonActive': i18n.translate('core.euiColumnSorting.buttonActive', { + defaultMessage: 'fields sorted', + }), 'euiColumnSortingDraggable.activeSortLabel': i18n.translate( 'core.euiColumnSortingDraggable.activeSortLabel', { @@ -185,11 +326,11 @@ export const getEuiContextMapping = () => { values={{ label }} /> ), - 'euiComboBoxOptionsList.createCustomOption': ({ key, searchValue }: EuiValues) => ( + 'euiComboBoxOptionsList.createCustomOption': ({ searchValue }: EuiValues) => ( ), 'euiComboBoxOptionsList.loadingOptions': i18n.translate( @@ -212,6 +353,12 @@ export const getEuiContextMapping = () => { values={{ searchValue }} /> ), + 'euiComboBoxOptionsList.delimiterMessage': ({ delimiter }: EuiValues) => + i18n.translate('core.euiComboBoxOptionsList.delimiterMessage', { + defaultMessage: 'Add each item separated by {delimiter}', + values: { delimiter }, + description: 'Screen reader text describing adding delimited options', + }), 'euiComboBoxPill.removeSelection': ({ children }: EuiValues) => i18n.translate('core.euiComboBoxPill.removeSelection', { defaultMessage: 'Remove {children} from selection in this group', @@ -224,20 +371,69 @@ export const getEuiContextMapping = () => { 'euiDataGrid.screenReaderNotice': i18n.translate('core.euiDataGrid.screenReaderNotice', { defaultMessage: 'Cell contains interactive content.', }), - 'euiDataGridCell.expandButtonTitle': i18n.translate('core.euiDataGridCell.expandButtonTitle', { - defaultMessage: 'Click or hit enter to interact with cell content', + 'euiDataGrid.ariaLabelGridPagination': ({ label }: EuiValues) => + i18n.translate('core.euiDataGrid.ariaLabelGridPagination', { + defaultMessage: 'Pagination for preceding grid: {label}', + values: { label }, + description: 'Screen reader text to describe the pagination controls', + }), + 'euiDataGrid.ariaLabelledByGridPagination': i18n.translate( + 'core.euiDataGrid.ariaLabelledByGridPagination', + { + defaultMessage: 'Pagination for preceding grid', + description: 'Screen reader text to describe the pagination controls', + } + ), + 'euiDataGrid.ariaLabel': ({ label, page, pageCount }: EuiValues) => + i18n.translate('core.euiDataGrid.ariaLabel', { + defaultMessage: '{label}; Page {page} of {pageCount}.', + values: { label, page, pageCount }, + description: 'Screen reader text to describe the size of the data grid', + }), + 'euiDataGrid.ariaLabelledBy': ({ page, pageCount }: EuiValues) => + i18n.translate('core.euiDataGrid.ariaLabelledBy', { + defaultMessage: 'Page {page} of {pageCount}.', + values: { page, pageCount }, + description: 'Screen reader text to describe the size of the data grid', + }), + 'euiDataGrid.fullScreenButton': i18n.translate('core.euiDataGrid.fullScreenButton', { + defaultMessage: 'Full screen', }), + 'euiDataGrid.fullScreenButtonActive': i18n.translate( + 'core.euiDataGrid.fullScreenButtonActive', + { + defaultMessage: 'Exit full screen', + } + ), + 'euiDataGridCell.row': i18n.translate('core.euiDataGridCell.row', { + defaultMessage: 'Row', + }), + 'euiDataGridCell.column': i18n.translate('core.euiDataGridCell.column', { + defaultMessage: 'Column', + }), + 'euiDataGridCellButtons.expandButtonTitle': i18n.translate( + 'core.euiDataGridCellButtons.expandButtonTitle', + { + defaultMessage: 'Click or hit enter to interact with cell content', + } + ), + 'euiDataGridHeaderCell.headerActions': i18n.translate( + 'core.euiDataGridHeaderCell.headerActions', + { + defaultMessage: 'Header actions', + } + ), 'euiDataGridSchema.booleanSortTextAsc': i18n.translate( 'core.euiDataGridSchema.booleanSortTextAsc', { - defaultMessage: 'True-False', + defaultMessage: 'False-True', description: 'Ascending boolean label', } ), 'euiDataGridSchema.booleanSortTextDesc': i18n.translate( 'core.euiDataGridSchema.booleanSortTextDesc', { - defaultMessage: 'False-True', + defaultMessage: 'True-False', description: 'Descending boolean label', } ), @@ -291,13 +487,29 @@ export const getEuiContextMapping = () => { description: 'Descending size label', } ), + 'euiFieldPassword.showPassword': i18n.translate('core.euiFieldPassword.showPassword', { + defaultMessage: + 'Show password as plain text. Note: this will visually expose your password on the screen.', + }), + 'euiFieldPassword.maskPassword': i18n.translate('core.euiFieldPassword.maskPassword', { + defaultMessage: 'Mask password', + }), + 'euiFilePicker.clearSelectedFiles': i18n.translate('core.euiFilePicker.clearSelectedFiles', { + defaultMessage: 'Clear selected files', + }), + 'euiFilePicker.filesSelected': i18n.translate('core.euiFilePicker.filesSelected', { + defaultMessage: 'files selected', + }), 'euiFilterButton.filterBadge': ({ count, hasActiveFilters }: EuiValues) => i18n.translate('core.euiFilterButton.filterBadge', { defaultMessage: '${count} ${filterCountLabel} filters', values: { count, filterCountLabel: hasActiveFilters ? 'active' : 'available' }, }), + 'euiFlyout.closeAriaLabel': i18n.translate('core.euiFlyout.closeAriaLabel', { + defaultMessage: 'Close this dialog', + }), 'euiForm.addressFormErrors': i18n.translate('core.euiForm.addressFormErrors', { - defaultMessage: 'Please address the errors in your form.', + defaultMessage: 'Please address the highlighted errors.', }), 'euiFormControlLayoutClearButton.label': i18n.translate( 'core.euiFormControlLayoutClearButton.label', @@ -311,11 +523,11 @@ export const getEuiContextMapping = () => { description: 'ARIA label on a button that dismisses/removes a notification', }), 'euiHeaderLinks.appNavigation': i18n.translate('core.euiHeaderLinks.appNavigation', { - defaultMessage: 'App navigation', + defaultMessage: 'App menu', description: 'ARIA label on a `nav` element', }), 'euiHeaderLinks.openNavigationMenu': i18n.translate('core.euiHeaderLinks.openNavigationMenu', { - defaultMessage: 'Open navigation menu', + defaultMessage: 'Open menu', }), 'euiHue.label': i18n.translate('core.euiHue.label', { defaultMessage: 'Select the HSV color mode "hue" value', @@ -333,31 +545,200 @@ export const getEuiContextMapping = () => { 'euiLink.external.ariaLabel': i18n.translate('core.euiLink.external.ariaLabel', { defaultMessage: 'External link', }), + 'euiLink.newTarget.screenReaderOnlyText': i18n.translate( + 'core.euiLink.newTarget.screenReaderOnlyText', + { + defaultMessage: '(opens in a new tab or window)', + } + ), + 'euiMarkdownEditorFooter.closeButton': i18n.translate( + 'core.euiMarkdownEditorFooter.closeButton', + { + defaultMessage: 'Close', + } + ), + 'euiMarkdownEditorFooter.uploadingFiles': i18n.translate( + 'core.euiMarkdownEditorFooter.uploadingFiles', + { + defaultMessage: 'Click to upload files', + } + ), + 'euiMarkdownEditorFooter.openUploadModal': i18n.translate( + 'core.euiMarkdownEditorFooter.openUploadModal', + { + defaultMessage: 'Open upload files modal', + } + ), + 'euiMarkdownEditorFooter.unsupportedFileType': i18n.translate( + 'core.euiMarkdownEditorFooter.unsupportedFileType', + { + defaultMessage: 'File type not supported', + } + ), + 'euiMarkdownEditorFooter.supportedFileTypes': ({ supportedFileTypes }: EuiValues) => + i18n.translate('core.euiMarkdownEditorFooter.supportedFileTypes', { + defaultMessage: 'Supported files: {supportedFileTypes}', + values: { supportedFileTypes }, + }), + 'euiMarkdownEditorFooter.showSyntaxErrors': i18n.translate( + 'core.euiMarkdownEditorFooter.showSyntaxErrors', + { + defaultMessage: 'Show errors', + } + ), + 'euiMarkdownEditorFooter.showMarkdownHelp': i18n.translate( + 'core.euiMarkdownEditorFooter.showMarkdownHelp', + { + defaultMessage: 'Show markdown help', + } + ), + 'euiMarkdownEditorFooter.errorsTitle': i18n.translate( + 'core.euiMarkdownEditorFooter.errorsTitle', + { + defaultMessage: 'Errors', + } + ), + 'euiMarkdownEditorFooter.syntaxTitle': i18n.translate( + 'core.euiMarkdownEditorFooter.syntaxTitle', + { + defaultMessage: 'Syntax help', + } + ), + 'euiMarkdownEditorFooter.descriptionPrefix': i18n.translate( + 'core.euiMarkdownEditorFooter.descriptionPrefix', + { + defaultMessage: 'This editor uses', + } + ), + 'euiMarkdownEditorFooter.descriptionSuffix': i18n.translate( + 'core.euiMarkdownEditorFooter.descriptionSuffix', + { + defaultMessage: + 'You can also utilize these additional syntax plugins to add rich content to your text.', + } + ), + 'euiMarkdownEditorToolbar.editor': i18n.translate('core.euiMarkdownEditorToolbar.editor', { + defaultMessage: 'Editor', + }), + 'euiMarkdownEditorToolbar.previewMarkdown': i18n.translate( + 'core.euiMarkdownEditorToolbar.previewMarkdown', + { + defaultMessage: 'Preview', + } + ), 'euiModal.closeModal': i18n.translate('core.euiModal.closeModal', { defaultMessage: 'Closes this modal window', }), - 'euiPagination.jumpToLastPage': ({ pageCount }: EuiValues) => - i18n.translate('core.euiPagination.jumpToLastPage', { - defaultMessage: 'Jump to the last page, number {pageCount}', - values: { pageCount }, + 'euiNotificationEventMessages.accordionButtonText': ({ + messagesLength, + eventName, + }: EuiValues) => + i18n.translate('core.euiNotificationEventMessages.accordionButtonText', { + defaultMessage: '+ {messagesLength} messages for {eventName}', + values: { messagesLength, eventName }, + }), + 'euiNotificationEventMessages.accordionAriaLabelButtonText': ({ messagesLength }: EuiValues) => + i18n.translate('core.euiNotificationEventMessages.accordionAriaLabelButtonText', { + defaultMessage: '+ {messagesLength} more', + values: { messagesLength }, + }), + 'euiNotificationEventMeta.contextMenuButton': ({ eventName }: EuiValues) => + i18n.translate('core.euiNotificationEventMeta.contextMenuButton', { + defaultMessage: 'Menu for {eventName}', + values: { eventName }, + }), + 'euiNotificationEventReadButton.markAsReadAria': ({ eventName }: EuiValues) => + i18n.translate('core.euiNotificationEventReadButton.markAsReadAria', { + defaultMessage: 'Mark {eventName} as read', + values: { eventName }, + }), + 'euiNotificationEventReadButton.markAsUnreadAria': ({ eventName }: EuiValues) => + i18n.translate('core.euiNotificationEventReadButton.markAsUnreadAria', { + defaultMessage: 'Mark {eventName} as unread', + values: { eventName }, + }), + 'euiNotificationEventReadButton.markAsRead': i18n.translate( + 'core.euiNotificationEventReadButton.markAsRead', + { + defaultMessage: 'Mark as read', + } + ), + 'euiNotificationEventReadButton.markAsUnread': i18n.translate( + 'core.euiNotificationEventReadButton.markAsUnread', + { + defaultMessage: 'Mark as unread', + } + ), + 'euiNotificationEventMessages.accordionHideText': i18n.translate( + 'core.euiNotificationEventMessages.accordionHideText', + { + defaultMessage: 'hide', + } + ), + 'euiPagination.nextPage': ({ page }: EuiValues) => + i18n.translate('core.euiPagination.nextPage', { + defaultMessage: 'Next page, {page}', + values: { page }, }), - 'euiPagination.nextPage': i18n.translate('core.euiPagination.nextPage', { + 'euiPagination.previousPage': ({ page }: EuiValues) => + i18n.translate('core.euiPagination.previousPage', { + defaultMessage: 'Previous page, {page}', + values: { page }, + }), + 'euiPagination.disabledPreviousPage': i18n.translate( + 'core.euiPagination.disabledPreviousPage', + { + defaultMessage: 'Previous page', + } + ), + 'euiPagination.disabledNextPage': i18n.translate('core.euiPagination.disabledNextPage', { defaultMessage: 'Next page', }), - 'euiPagination.pageOfTotal': ({ page, total }: EuiValues) => - i18n.translate('core.euiPagination.pageOfTotal', { - defaultMessage: 'Page {page} of {total}', - values: { page, total }, + 'euiPagination.firstRangeAriaLabel': ({ lastPage }: EuiValues) => + i18n.translate('core.euiPagination.firstRangeAriaLabel', { + defaultMessage: 'Skipping pages 2 to {lastPage}', + values: { lastPage }, }), - 'euiPagination.previousPage': i18n.translate('core.euiPagination.previousPage', { - defaultMessage: 'Previous page', - }), + 'euiPagination.lastRangeAriaLabel': ({ firstPage, lastPage }: EuiValues) => + i18n.translate('core.euiPagination.lastRangeAriaLabel', { + defaultMessage: 'Skipping pages {firstPage} to {lastPage}', + values: { firstPage, lastPage }, + }), + 'euiPaginationButton.longPageString': ({ page, totalPages }: EuiValues) => + i18n.translate('core.euiPaginationButton.longPageString', { + defaultMessage: 'Page {page} of {totalPages}', + values: { page, totalPages }, + description: 'Text to describe the size of a paginated section', + }), + 'euiPaginationButton.shortPageString': ({ page }: EuiValues) => + i18n.translate('core.euiPaginationButton.shortPageString', { + defaultMessage: 'Page {page}', + values: { page }, + description: 'Text to describe the current page of a paginated section', + }), + 'euiPinnableListGroup.pinExtraActionLabel': i18n.translate( + 'core.euiPinnableListGroup.pinExtraActionLabel', + { + defaultMessage: 'Pin item', + } + ), + 'euiPinnableListGroup.pinnedExtraActionLabel': i18n.translate( + 'core.euiPinnableListGroup.pinnedExtraActionLabel', + { + defaultMessage: 'Unpin item', + } + ), 'euiPopover.screenReaderAnnouncement': i18n.translate( 'core.euiPopover.screenReaderAnnouncement', { defaultMessage: 'You are in a dialog. To close this dialog, hit escape.', } ), + 'euiProgress.valueText': ({ value }: EuiValues) => + i18n.translate('core.euiProgress.valueText', { + defaultMessage: '{value}%', + values: { value }, + }), 'euiQuickSelect.applyButton': i18n.translate('core.euiQuickSelect.applyButton', { defaultMessage: 'Apply', }), @@ -387,9 +768,12 @@ export const getEuiContextMapping = () => { 'euiQuickSelect.valueLabel': i18n.translate('core.euiQuickSelect.valueLabel', { defaultMessage: 'Time value', }), + 'euiRecentlyUsed.legend': i18n.translate('core.euiRecentlyUsed.legend', { + defaultMessage: 'Recently used date ranges', + }), 'euiRefreshInterval.fullDescription': ({ optionValue, optionText }: EuiValues) => i18n.translate('core.euiRefreshInterval.fullDescription', { - defaultMessage: 'Currently set to {optionValue} {optionText}.', + defaultMessage: 'Refresh interval currently set to {optionValue} {optionText}.', values: { optionValue, optionText }, }), 'euiRefreshInterval.legend': i18n.translate('core.euiRefreshInterval.legend', { @@ -419,6 +803,30 @@ export const getEuiContextMapping = () => { 'euiRelativeTab.unitInputLabel': i18n.translate('core.euiRelativeTab.unitInputLabel', { defaultMessage: 'Relative time span', }), + 'euiRelativeTab.numberInputError': i18n.translate('core.euiRelativeTab.numberInputError', { + defaultMessage: 'Must be >= 0', + }), + 'euiRelativeTab.numberInputLabel': i18n.translate('core.euiRelativeTab.numberInputLabel', { + defaultMessage: 'Time span amount', + }), + 'euiResizableButton.horizontalResizerAriaLabel': i18n.translate( + 'core.euiResizableButton.horizontalResizerAriaLabel', + { + defaultMessage: 'Press left or right to adjust panels size', + } + ), + 'euiResizableButton.verticalResizerAriaLabel': i18n.translate( + 'core.euiResizableButton.verticalResizerAriaLabel', + { + defaultMessage: 'Press up or down to adjust panels size', + } + ), + 'euiResizablePanel.toggleButtonAriaLabel': i18n.translate( + 'core.euiResizablePanel.toggleButtonAriaLabel', + { + defaultMessage: 'Press to toggle this panel', + } + ), 'euiSaturation.roleDescription': i18n.translate('core.euiSaturation.roleDescription', { defaultMessage: 'HSV color mode saturation and value selection', }), @@ -443,46 +851,145 @@ export const getEuiContextMapping = () => { values={{ searchValue }} /> ), + 'euiSelectable.placeholderName': i18n.translate('core.euiSelectable.placeholderName', { + defaultMessage: 'Filter options', + }), + 'euiSelectableListItem.includedOption': i18n.translate( + 'core.euiSelectableListItem.includedOption', + { + defaultMessage: 'Included option.', + } + ), + 'euiSelectableListItem.includedOptionInstructions': i18n.translate( + 'core.euiSelectableListItem.includedOptionInstructions', + { + defaultMessage: 'To exclude this option, press enter.', + } + ), + 'euiSelectableListItem.excludedOption': i18n.translate( + 'core.euiSelectableListItem.excludedOption', + { + defaultMessage: 'Excluded option.', + } + ), + 'euiSelectableListItem.excludedOptionInstructions': i18n.translate( + 'core.euiSelectableListItem.excludedOptionInstructions', + { + defaultMessage: 'To deselect this option, press enter', + } + ), + 'euiSelectableTemplateSitewide.loadingResults': i18n.translate( + 'core.euiSelectableTemplateSitewide.loadingResults', + { + defaultMessage: 'Loading results', + } + ), + 'euiSelectableTemplateSitewide.noResults': i18n.translate( + 'core.euiSelectableTemplateSitewide.noResults', + { + defaultMessage: 'No results available', + } + ), + 'euiSelectableTemplateSitewide.onFocusBadgeGoTo': i18n.translate( + 'core.euiSelectableTemplateSitewide.onFocusBadgeGoTo', + { + defaultMessage: 'Go to', + } + ), + 'euiSelectableTemplateSitewide.searchPlaceholder': i18n.translate( + 'core.euiSelectableTemplateSitewide.searchPlaceholder', + { + defaultMessage: 'Search for anything...', + } + ), 'euiStat.loadingText': i18n.translate('core.euiStat.loadingText', { defaultMessage: 'Statistic is loading', }), - 'euiStep.ariaLabel': ({ status }: EuiValues) => - i18n.translate('core.euiStep.ariaLabel', { - defaultMessage: '{stepStatus}', - values: { stepStatus: status === 'incomplete' ? 'Incomplete Step' : 'Step' }, - }), - 'euiStepHorizontal.buttonTitle': ({ step, title, disabled, isComplete }: EuiValues) => { - return i18n.translate('core.euiStepHorizontal.buttonTitle', { - defaultMessage: 'Step {step}: {title}{titleAppendix}', - values: { - step, - title, - titleAppendix: disabled ? ' is disabled' : isComplete ? ' is complete' : '', - }, - }); - }, - 'euiStepHorizontal.step': i18n.translate('core.euiStepHorizontal.step', { - defaultMessage: 'Step', - description: 'Screen reader text announcing information about a step in some process', - }), - 'euiStepNumber.hasErrors': i18n.translate('core.euiStepNumber.hasErrors', { - defaultMessage: 'has errors', - description: - 'Used as the title attribute on an image or svg icon to indicate a given process step has errors', - }), - 'euiStepNumber.hasWarnings': i18n.translate('core.euiStepNumber.hasWarnings', { - defaultMessage: 'has warnings', - description: - 'Used as the title attribute on an image or svg icon to indicate a given process step has warnings', - }), - 'euiStepNumber.isComplete': i18n.translate('core.euiStepNumber.isComplete', { - defaultMessage: 'complete', - description: - 'Used as the title attribute on an image or svg icon to indicate a given process step is complete', - }), + 'euiStepStrings.step': ({ number, title }: EuiValues) => + i18n.translate('core.euiStepStrings.step', { + defaultMessage: 'Step {number}: {title}', + values: { number, title }, + }), + 'euiStepStrings.simpleStep': ({ number }: EuiValues) => + i18n.translate('core.euiStepStrings.simpleStep', { + defaultMessage: 'Step {number}', + values: { number }, + }), + 'euiStepStrings.complete': ({ number, title }: EuiValues) => + i18n.translate('core.euiStepStrings.complete', { + defaultMessage: 'Step {number}: {title} is complete', + values: { number, title }, + }), + 'euiStepStrings.simpleComplete': ({ number }: EuiValues) => + i18n.translate('core.euiStepStrings.simpleComplete', { + defaultMessage: 'Step {number} is complete', + values: { number }, + }), + 'euiStepStrings.warning': ({ number, title }: EuiValues) => + i18n.translate('core.euiStepStrings.warning', { + defaultMessage: 'Step {number}: {title} has warnings', + values: { number, title }, + }), + 'euiStepStrings.simpleWarning': ({ number }: EuiValues) => + i18n.translate('core.euiStepStrings.simpleWarning', { + defaultMessage: 'Step {number} has warnings', + values: { number }, + }), + 'euiStepStrings.errors': ({ number, title }: EuiValues) => + i18n.translate('core.euiStepStrings.errors', { + defaultMessage: 'Step {number}: {title} has errors', + values: { number, title }, + }), + 'euiStepStrings.simpleErrors': ({ number }: EuiValues) => + i18n.translate('core.euiStepStrings.simpleErrors', { + defaultMessage: 'Step {number} has errors', + values: { number }, + }), + 'euiStepStrings.incomplete': ({ number, title }: EuiValues) => + i18n.translate('core.euiStepStrings.incomplete', { + defaultMessage: 'Step {number}: {title} is incomplete', + values: { number, title }, + }), + 'euiStepStrings.simpleIncomplete': ({ number }: EuiValues) => + i18n.translate('core.euiStepStrings.simpleIncomplete', { + defaultMessage: 'Step {number} is incomplete', + values: { number }, + }), + 'euiStepStrings.disabled': ({ number, title }: EuiValues) => + i18n.translate('core.euiStepStrings.disabled', { + defaultMessage: 'Step {number}: {title} is disabled', + values: { number, title }, + }), + 'euiStepStrings.simpleDisabled': ({ number }: EuiValues) => + i18n.translate('core.euiStepStrings.simpleDisabled', { + defaultMessage: 'Step {number} is disabled', + values: { number }, + }), + 'euiStepStrings.loading': ({ number, title }: EuiValues) => + i18n.translate('core.euiStepStrings.loading', { + defaultMessage: 'Step {number}: {title} is loading', + values: { number, title }, + }), + 'euiStepStrings.simpleLoading': ({ number }: EuiValues) => + i18n.translate('core.euiStepStrings.simpleLoading', { + defaultMessage: 'Step {number} is loading', + values: { number }, + }), 'euiStyleSelector.buttonText': i18n.translate('core.euiStyleSelector.buttonText', { defaultMessage: 'Density', }), + 'euiStyleSelector.buttonLegend': i18n.translate('core.euiStyleSelector.buttonLegend', { + defaultMessage: 'Select the display density for the data grid', + }), + 'euiStyleSelector.labelExpanded': i18n.translate('core.euiStyleSelector.labelExpanded', { + defaultMessage: 'Expanded density', + }), + 'euiStyleSelector.labelNormal': i18n.translate('core.euiStyleSelector.labelNormal', { + defaultMessage: 'Normal density', + }), + 'euiStyleSelector.labelCompact': i18n.translate('core.euiStyleSelector.labelCompact', { + defaultMessage: 'Compact density', + }), 'euiSuperDatePicker.showDatesButtonLabel': i18n.translate( 'core.euiSuperDatePicker.showDatesButtonLabel', { @@ -536,6 +1043,30 @@ export const getEuiContextMapping = () => { description: 'Displayed in a button that updates based on date picked', } ), + 'euiTableHeaderCell.clickForAscending': i18n.translate( + 'core.euiTableHeaderCell.clickForAscending', + { + defaultMessage: 'Click to sort in ascending order', + description: 'Displayed in a button that toggles a table sorting', + } + ), + 'euiTableHeaderCell.clickForDescending': i18n.translate( + 'core.euiTableHeaderCell.clickForDescending', + { + defaultMessage: 'Click to sort in descending order', + description: 'Displayed in a button that toggles a table sorting', + } + ), + 'euiTableHeaderCell.clickForUnsort': i18n.translate('core.euiTableHeaderCell.clickForUnsort', { + defaultMessage: 'Click to unsort', + description: 'Displayed in a button that toggles a table sorting', + }), + 'euiTableHeaderCell.titleTextWithSort': ({ innerText, ariaSortValue }: EuiValues) => + i18n.translate('core.euiTableHeaderCell.titleTextWithSort', { + defaultMessage: '{innerText}; Sorted in {ariaSortValue} order', + values: { innerText, ariaSortValue }, + description: 'Text describing the table sort order', + }), 'euiTablePagination.rowsPerPage': i18n.translate('core.euiTablePagination.rowsPerPage', { defaultMessage: 'Rows per page', description: 'Displayed in a button that toggles a table pagination menu', @@ -560,6 +1091,33 @@ export const getEuiContextMapping = () => { defaultMessage: 'Notification', description: 'ARIA label on an element containing a notification', }), + 'euiTour.endTour': i18n.translate('core.euiTour.endTour', { + defaultMessage: 'End tour', + }), + 'euiTour.skipTour': i18n.translate('core.euiTour.skipTour', { + defaultMessage: 'Skip tour', + }), + 'euiTour.closeTour': i18n.translate('core.euiTour.closeTour', { + defaultMessage: 'Close tour', + }), + 'euiTourStepIndicator.isActive': i18n.translate('core.euiTourStepIndicator.isActive', { + defaultMessage: 'active', + description: 'Text for an active tour step', + }), + 'euiTourStepIndicator.isComplete': i18n.translate('core.euiTourStepIndicator.isComplete', { + defaultMessage: 'complete', + description: 'Text for a completed tour step', + }), + 'euiTourStepIndicator.isIncomplete': i18n.translate('core.euiTourStepIndicator.isIncomplete', { + defaultMessage: 'incomplete', + description: 'Text for an incomplete tour step', + }), + 'euiTourStepIndicator.ariaLabel': ({ status, number }: EuiValues) => + i18n.translate('core.euiTourStepIndicator.ariaLabel', { + defaultMessage: 'Step {number} {status}', + values: { status, number }, + description: 'Screen reader text describing the state of a tour step', + }), 'euiTreeView.ariaLabel': ({ nodeLabel, ariaLabel }: EuiValues) => i18n.translate('core.euiTreeView.ariaLabel', { defaultMessage: '{nodeLabel} child of {ariaLabel}', diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8c1753c2cabab..b3ded52a98171 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -490,6 +490,9 @@ export interface DocLinksStart { readonly ELASTIC_WEBSITE_URL: string; // (undocumented) readonly links: { + readonly canvas: { + readonly guide: string; + }; readonly dashboard: { readonly guide: string; readonly drilldowns: string; @@ -1224,7 +1227,7 @@ export class SavedObjectsClient { // Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts delete: (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType; // Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts - find: (options: SavedObjectsFindOptions_2) => Promise>; + find: (options: SavedObjectsFindOptions_2) => Promise>; get: (type: string, id: string) => Promise>; update(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise>; } @@ -1244,6 +1247,8 @@ export interface SavedObjectsCreateOptions { // @public (undocumented) export interface SavedObjectsFindOptions { + // @alpha + aggs?: Record; defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts @@ -1284,7 +1289,9 @@ export interface SavedObjectsFindOptionsReference { } // @public -export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { +export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { + // (undocumented) + aggregations?: A; // (undocumented) page: number; // (undocumented) diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 44466025de7e3..782ffa6897048 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -103,7 +103,9 @@ export interface SavedObjectsDeleteOptions { * * @public */ -export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { +export interface SavedObjectsFindResponsePublic + extends SavedObjectsBatchResponse { + aggregations?: A; total: number; perPage: number; page: number; @@ -310,7 +312,7 @@ export class SavedObjectsClient { * @property {object} [options.hasReference] - { type, id } * @returns A find result with objects matching the specified search. */ - public find = ( + public find = ( options: SavedObjectsFindOptions ): Promise> => { const path = this.getPath(['_find']); @@ -326,6 +328,7 @@ export class SavedObjectsClient { sortField: 'sort_field', type: 'type', filter: 'filter', + aggs: 'aggs', namespaces: 'namespaces', preference: 'preference', }; @@ -342,6 +345,12 @@ export class SavedObjectsClient { query.has_reference = JSON.stringify(query.has_reference); } + // `aggs` is a structured object. we need to stringify it before sending it, as `fetch` + // is not doing it implicitly. + if (query.aggs) { + query.aggs = JSON.stringify(query.aggs); + } + const request: ReturnType = this.savedObjectsFetch(path, { method: 'GET', query, @@ -349,6 +358,7 @@ export class SavedObjectsClient { return request.then((resp) => { return renameKeys( { + aggregations: 'aggregations', saved_objects: 'savedObjects', total: 'total', per_page: 'perPage', diff --git a/src/core/server/core_app/core_app.ts b/src/core/server/core_app/core_app.ts index bc1098832bac5..e728cb0b82475 100644 --- a/src/core/server/core_app/core_app.ts +++ b/src/core/server/core_app/core_app.ts @@ -65,7 +65,7 @@ export class CoreApp { async (context, req, res) => { const { query, params } = req; const { path } = params; - if (!path || !path.endsWith('/')) { + if (!path || !path.endsWith('/') || path.startsWith('/')) { return res.notFound(); } diff --git a/src/core/server/core_app/integration_tests/core_app_routes.test.ts b/src/core/server/core_app/integration_tests/core_app_routes.test.ts index 6b0643f7d1bc7..faa1c905afa9d 100644 --- a/src/core/server/core_app/integration_tests/core_app_routes.test.ts +++ b/src/core/server/core_app/integration_tests/core_app_routes.test.ts @@ -39,6 +39,10 @@ describe('Core app routes', () => { expect(response.get('location')).toEqual('/base-path/some-path?foo=bar'); }); + it('does not redirect if the path starts with `//`', async () => { + await kbnTestServer.request.get(root, '//some-path/').expect(404); + }); + it('does not redirect if the path does not end with `/`', async () => { await kbnTestServer.request.get(root, '/some-path').expect(404); }); 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 8ed627cebec7e..e09f595747c30 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 @@ -95,6 +95,13 @@ const createStartContractMock = () => { supportedProtocols: ['TLSv1.1', 'TLSv1.2'], truststoreConfigured: false, }, + securityResponseHeaders: { + strictTransportSecurity: 'NULL', // `null` values are coalesced to `"NULL"` strings + xContentTypeOptions: 'nosniff', + referrerPolicy: 'no-referrer-when-downgrade', + permissionsPolicyConfigured: false, + disableEmbedding: false, + }, xsrf: { disableProtection: false, allowlistConfigured: false, @@ -132,6 +139,7 @@ const createStartContractMock = () => { }, }) ), + getConfigsUsageData: jest.fn(), }; return startContract; 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 1c28eca1f1dec..dc74b65c8dcfc 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 @@ -35,7 +35,35 @@ describe('CoreUsageDataService', () => { }); let service: CoreUsageDataService; - const configService = configServiceMock.create(); + const mockConfig = { + unused_config: {}, + elasticsearch: { username: 'kibana_system', password: 'changeme' }, + plugins: { paths: ['pluginA', 'pluginAB', 'pluginB'] }, + server: { port: 5603, basePath: '/zvt', rewriteBasePath: true }, + logging: { json: false }, + pluginA: { + enabled: true, + objectConfig: { + debug: true, + username: 'some_user', + }, + arrayOfNumbers: [1, 2, 3], + }, + pluginAB: { + enabled: false, + }, + pluginB: { + arrayOfObjects: [ + { propA: 'a', propB: 'b' }, + { propA: 'a2', propB: 'b2' }, + ], + }, + }; + + const configService = configServiceMock.create({ + getConfig$: mockConfig, + }); + configService.atPath.mockImplementation((path) => { if (path === 'elasticsearch') { return new BehaviorSubject(RawElasticsearchConfig.schema.validate({})); @@ -146,6 +174,7 @@ describe('CoreUsageDataService', () => { const { getCoreUsageData } = service.start({ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage: new Map(), elasticsearch, }); expect(getCoreUsageData()).resolves.toMatchInlineSnapshot(` @@ -187,6 +216,13 @@ describe('CoreUsageDataService', () => { "ipAllowlistConfigured": false, }, "rewriteBasePath": false, + "securityResponseHeaders": Object { + "disableEmbedding": false, + "permissionsPolicyConfigured": false, + "referrerPolicy": "no-referrer-when-downgrade", + "strictTransportSecurity": "NULL", + "xContentTypeOptions": "nosniff", + }, "socketTimeout": 120000, "ssl": Object { "certificateAuthoritiesConfigured": false, @@ -274,6 +310,453 @@ describe('CoreUsageDataService', () => { `); }); }); + + describe('getConfigsUsageData', () => { + const elasticsearch = elasticsearchServiceMock.createStart(); + const typeRegistry = savedObjectsServiceMock.createTypeRegistryMock(); + let exposedConfigsToUsage: Map>; + beforeEach(() => { + exposedConfigsToUsage = new Map(); + }); + + it('loops over all used configs once each', async () => { + configService.getUsedPaths.mockResolvedValue([ + 'pluginA.objectConfig.debug', + 'logging.json', + ]); + + exposedConfigsToUsage.set('pluginA', { + objectConfig: true, + }); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + const mockGetMarkedAsSafe = jest.fn().mockReturnValue({}); + // @ts-expect-error + service.getMarkedAsSafe = mockGetMarkedAsSafe; + await getConfigsUsageData(); + + expect(mockGetMarkedAsSafe).toBeCalledTimes(2); + expect(mockGetMarkedAsSafe.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Map { + "pluginA" => Object { + "objectConfig": true, + }, + }, + "pluginA.objectConfig.debug", + "pluginA", + ], + Array [ + Map { + "pluginA" => Object { + "objectConfig": true, + }, + }, + "logging.json", + undefined, + ], + ] + `); + }); + + it('plucks pluginId from config path correctly', async () => { + exposedConfigsToUsage.set('pluginA', { + enabled: false, + }); + exposedConfigsToUsage.set('pluginAB', { + enabled: false, + }); + + configService.getUsedPaths.mockResolvedValue(['pluginA.enabled', 'pluginAB.enabled']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginA.enabled": "[redacted]", + "pluginAB.enabled": "[redacted]", + } + `); + }); + + it('returns an object of plugin config usage', async () => { + exposedConfigsToUsage.set('unused_config', { never_reported: true }); + exposedConfigsToUsage.set('server', { basePath: true }); + exposedConfigsToUsage.set('pluginA', { elasticsearch: false }); + exposedConfigsToUsage.set('plugins', { paths: false }); + exposedConfigsToUsage.set('pluginA', { arrayOfNumbers: false }); + + configService.getUsedPaths.mockResolvedValue([ + 'elasticsearch.username', + 'elasticsearch.password', + 'plugins.paths', + 'server.port', + 'server.basePath', + 'server.rewriteBasePath', + 'logging.json', + 'pluginA.enabled', + 'pluginA.objectConfig.debug', + 'pluginA.objectConfig.username', + 'pluginA.arrayOfNumbers', + 'pluginAB.enabled', + 'pluginB.arrayOfObjects', + ]); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "elasticsearch.password": "[redacted]", + "elasticsearch.username": "[redacted]", + "logging.json": false, + "pluginA.arrayOfNumbers": "[redacted]", + "pluginA.enabled": true, + "pluginA.objectConfig.debug": true, + "pluginA.objectConfig.username": "[redacted]", + "pluginAB.enabled": false, + "pluginB.arrayOfObjects": "[redacted]", + "plugins.paths": "[redacted]", + "server.basePath": "/zvt", + "server.port": 5603, + "server.rewriteBasePath": true, + } + `); + }); + + describe('config explicitly exposed to usage', () => { + it('returns [redacted] on unsafe complete match', async () => { + exposedConfigsToUsage.set('pluginA', { + 'objectConfig.debug': false, + }); + exposedConfigsToUsage.set('server', { + basePath: false, + }); + + configService.getUsedPaths.mockResolvedValue([ + 'pluginA.objectConfig.debug', + 'server.basePath', + ]); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginA.objectConfig.debug": "[redacted]", + "server.basePath": "[redacted]", + } + `); + }); + + it('returns config value on safe complete match', async () => { + exposedConfigsToUsage.set('server', { + basePath: true, + }); + + configService.getUsedPaths.mockResolvedValue(['server.basePath']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "server.basePath": "/zvt", + } + `); + }); + + it('returns [redacted] on unsafe parent match', async () => { + exposedConfigsToUsage.set('pluginA', { + objectConfig: false, + }); + + configService.getUsedPaths.mockResolvedValue([ + 'pluginA.objectConfig.debug', + 'pluginA.objectConfig.username', + ]); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginA.objectConfig.debug": "[redacted]", + "pluginA.objectConfig.username": "[redacted]", + } + `); + }); + + it('returns config value on safe parent match', async () => { + exposedConfigsToUsage.set('pluginA', { + objectConfig: true, + }); + + configService.getUsedPaths.mockResolvedValue([ + 'pluginA.objectConfig.debug', + 'pluginA.objectConfig.username', + ]); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginA.objectConfig.debug": true, + "pluginA.objectConfig.username": "some_user", + } + `); + }); + + it('returns [redacted] on explicitly marked as safe array of objects', async () => { + exposedConfigsToUsage.set('pluginB', { + arrayOfObjects: true, + }); + + configService.getUsedPaths.mockResolvedValue(['pluginB.arrayOfObjects']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginB.arrayOfObjects": "[redacted]", + } + `); + }); + + it('returns values on explicitly marked as safe array of numbers', async () => { + exposedConfigsToUsage.set('pluginA', { + arrayOfNumbers: true, + }); + + configService.getUsedPaths.mockResolvedValue(['pluginA.arrayOfNumbers']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginA.arrayOfNumbers": Array [ + 1, + 2, + 3, + ], + } + `); + }); + + it('returns values on explicitly marked as safe array of strings', async () => { + exposedConfigsToUsage.set('plugins', { + paths: true, + }); + + configService.getUsedPaths.mockResolvedValue(['plugins.paths']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "plugins.paths": Array [ + "pluginA", + "pluginAB", + "pluginB", + ], + } + `); + }); + }); + + describe('config not explicitly exposed to usage', () => { + it('returns [redacted] for string configs', async () => { + exposedConfigsToUsage.set('pluginA', { + objectConfig: false, + }); + + configService.getUsedPaths.mockResolvedValue([ + 'pluginA.objectConfig.debug', + 'pluginA.objectConfig.username', + ]); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginA.objectConfig.debug": "[redacted]", + "pluginA.objectConfig.username": "[redacted]", + } + `); + }); + + it('returns config value on safe parent match', async () => { + configService.getUsedPaths.mockResolvedValue([ + 'elasticsearch.password', + 'elasticsearch.username', + 'pluginA.objectConfig.username', + ]); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "elasticsearch.password": "[redacted]", + "elasticsearch.username": "[redacted]", + "pluginA.objectConfig.username": "[redacted]", + } + `); + }); + + it('returns [redacted] on implicit array of objects', async () => { + configService.getUsedPaths.mockResolvedValue(['pluginB.arrayOfObjects']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginB.arrayOfObjects": "[redacted]", + } + `); + }); + + it('returns values on implicit array of numbers', async () => { + configService.getUsedPaths.mockResolvedValue(['pluginA.arrayOfNumbers']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "pluginA.arrayOfNumbers": Array [ + 1, + 2, + 3, + ], + } + `); + }); + it('returns [redacted] on implicit array of strings', async () => { + configService.getUsedPaths.mockResolvedValue(['plugins.paths']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "plugins.paths": "[redacted]", + } + `); + }); + + it('returns config value for numbers', async () => { + configService.getUsedPaths.mockResolvedValue(['server.port']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "server.port": 5603, + } + `); + }); + + it('returns config value for booleans', async () => { + configService.getUsedPaths.mockResolvedValue([ + 'pluginA.objectConfig.debug', + 'logging.json', + ]); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "logging.json": false, + "pluginA.objectConfig.debug": true, + } + `); + }); + + it('ignores exposed to usage configs but not used', async () => { + exposedConfigsToUsage.set('pluginA', { + objectConfig: true, + }); + + configService.getUsedPaths.mockResolvedValue(['logging.json']); + + const { getConfigsUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + exposedConfigsToUsage, + elasticsearch, + }); + + await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "logging.json": false, + } + `); + }); + }); + }); }); describe('setup and stop', () => { 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 dff68bf1c524f..85abdca9ea5dc 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 @@ -7,7 +7,9 @@ */ import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { takeUntil, first } from 'rxjs/operators'; +import { get } from 'lodash'; +import { hasConfigPathIntersection } from '@kbn/config'; import { CoreService } from 'src/core/types'; import { Logger, SavedObjectsServiceStart, SavedObjectTypeRegistry } from 'src/core/server'; @@ -16,11 +18,12 @@ import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config'; import { HttpConfigType, InternalHttpServiceSetup } from '../http'; import { LoggingConfigType } from '../logging'; import { SavedObjectsConfigType } from '../saved_objects/saved_objects_config'; -import { +import type { CoreServicesUsageData, CoreUsageData, CoreUsageDataStart, CoreUsageDataSetup, + ConfigUsageData, } from './types'; import { isConfigured } from './is_configured'; import { ElasticsearchServiceStart } from '../elasticsearch'; @@ -30,6 +33,8 @@ import { CORE_USAGE_STATS_TYPE } from './constants'; import { CoreUsageStatsClient } from './core_usage_stats_client'; import { MetricsServiceSetup, OpsMetrics } from '..'; +export type ExposedConfigsToUsage = Map>; + export interface SetupDeps { http: InternalHttpServiceSetup; metrics: MetricsServiceSetup; @@ -39,6 +44,7 @@ export interface SetupDeps { export interface StartDeps { savedObjects: SavedObjectsServiceStart; elasticsearch: ElasticsearchServiceStart; + exposedConfigsToUsage: ExposedConfigsToUsage; } /** @@ -225,6 +231,16 @@ export class CoreUsageDataService implements CoreService { + const fullPath = `${pluginId}.${exposeKey}`; + return hasConfigPathIntersection(usedPath, fullPath); + }); + + if (exposeKeyDetails) { + const explicitlyMarkedAsSafe = exposeDetails[exposeKeyDetails]; + + if (typeof explicitlyMarkedAsSafe === 'boolean') { + return { + explicitlyMarked: true, + isSafe: explicitlyMarkedAsSafe, + }; + } + } + } + + return { explicitlyMarked: false, isSafe: false }; + } + + private async getNonDefaultKibanaConfigs( + exposedConfigsToUsage: ExposedConfigsToUsage + ): Promise { + const config = await this.configService.getConfig$().pipe(first()).toPromise(); + const nonDefaultConfigs = config.toRaw(); + const usedPaths = await this.configService.getUsedPaths(); + const exposedConfigsKeys = [...exposedConfigsToUsage.keys()]; + + return usedPaths.reduce((acc, usedPath) => { + const rawConfigValue = get(nonDefaultConfigs, usedPath); + const pluginId = exposedConfigsKeys.find( + (exposedConfigsKey) => + usedPath === exposedConfigsKey || usedPath.startsWith(`${exposedConfigsKey}.`) + ); + + const { explicitlyMarked, isSafe } = this.getMarkedAsSafe( + exposedConfigsToUsage, + usedPath, + pluginId + ); + + // explicitly marked as safe + if (explicitlyMarked && isSafe) { + // report array of objects as redacted even if explicitly marked as safe. + // TS typings prevent explicitly marking arrays of objects as safe + // this makes sure to report redacted even if TS was bypassed. + if ( + Array.isArray(rawConfigValue) && + rawConfigValue.some((item) => typeof item === 'object') + ) { + acc[usedPath] = '[redacted]'; + } else { + acc[usedPath] = rawConfigValue; + } + } + + // explicitly marked as unsafe + if (explicitlyMarked && !isSafe) { + acc[usedPath] = '[redacted]'; + } + + /** + * not all types of values may contain sensitive values. + * Report boolean and number configs if not explicitly marked as unsafe. + */ + if (!explicitlyMarked) { + switch (typeof rawConfigValue) { + case 'number': + case 'boolean': + acc[usedPath] = rawConfigValue; + break; + case 'undefined': + acc[usedPath] = 'undefined'; + break; + case 'object': { + // non-array object types are already handled + if (Array.isArray(rawConfigValue)) { + if ( + rawConfigValue.every( + (item) => typeof item === 'number' || typeof item === 'boolean' + ) + ) { + acc[usedPath] = rawConfigValue; + break; + } + } + } + default: { + acc[usedPath] = '[redacted]'; + } + } + } + + return acc; + }, {} as Record); + } + setup({ http, metrics, savedObjectsStartPromise }: SetupDeps) { metrics .getOpsMetrics$() @@ -316,10 +436,13 @@ export class CoreUsageDataService implements CoreService { - return this.getCoreUsageData(savedObjects, elasticsearch); + getCoreUsageData: async () => { + return await this.getCoreUsageData(savedObjects, elasticsearch); + }, + getConfigsUsageData: async () => { + return await this.getNonDefaultKibanaConfigs(exposedConfigsToUsage); }, }; } diff --git a/src/core/server/core_usage_data/index.ts b/src/core/server/core_usage_data/index.ts index 4e0200ed1e4ea..638fc65522433 100644 --- a/src/core/server/core_usage_data/index.ts +++ b/src/core/server/core_usage_data/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export type { CoreUsageDataSetup, CoreUsageDataStart } from './types'; +export type { CoreUsageDataSetup, ConfigUsageData, CoreUsageDataStart } from './types'; export { CoreUsageDataService } from './core_usage_data_service'; export { CoreUsageStatsClient } from './core_usage_stats_client'; diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts index 46148e314bfee..1d5ef6d893f53 100644 --- a/src/core/server/core_usage_data/types.ts +++ b/src/core/server/core_usage_data/types.ts @@ -122,6 +122,18 @@ export interface CoreUsageData extends CoreUsageStats { environment: CoreEnvironmentUsageData; } +/** + * Type describing Core's usage data payload + * @internal + */ +export type ConfigUsageData = Record; + +/** + * Type describing Core's usage data payload + * @internal + */ +export type ExposedConfigsToUsage = Map>; + /** * Usage data from Core services * @internal @@ -212,6 +224,13 @@ export interface CoreConfigUsageData { supportedProtocols: string[]; clientAuthentication: 'none' | 'optional' | 'required'; }; + securityResponseHeaders: { + strictTransportSecurity: string; + xContentTypeOptions: string; + referrerPolicy: string; + permissionsPolicyConfigured: boolean; + disableEmbedding: boolean; + }; }; logging: { @@ -263,4 +282,5 @@ export interface CoreUsageDataStart { * @internal * */ getCoreUsageData(): Promise; + getConfigsUsageData(): Promise; } diff --git a/src/core/server/csp/config.test.ts b/src/core/server/csp/config.test.ts new file mode 100644 index 0000000000000..c7f6c4a214fac --- /dev/null +++ b/src/core/server/csp/config.test.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 { config } from './config'; + +describe('config.validate()', () => { + test(`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.0]: expected value to equal [false]' + ); + }); +}); diff --git a/src/core/server/csp/config.ts b/src/core/server/csp/config.ts index 3fc9faa26179e..a61fa1b03a45c 100644 --- a/src/core/server/csp/config.ts +++ b/src/core/server/csp/config.ts @@ -27,5 +27,8 @@ export const config = { }), strict: schema.boolean({ defaultValue: true }), warnLegacyBrowsers: schema.boolean({ defaultValue: true }), + disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }), }), }; + +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 ed13d363c4166..1e023c6f08ea8 100644 --- a/src/core/server/csp/csp_config.test.ts +++ b/src/core/server/csp/csp_config.test.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { CspConfig } from '.'; +import { CspConfig } from './csp_config'; +import { FRAME_ANCESTORS_RULE } 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, @@ -25,6 +26,7 @@ describe('CspConfig', () => { test('DEFAULT', () => { expect(CspConfig.DEFAULT).toMatchInlineSnapshot(` CspConfig { + "disableEmbedding": false, "header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", "rules": Array [ "script-src 'unsafe-eval' 'self'", @@ -38,49 +40,51 @@ describe('CspConfig', () => { }); test('defaults from config', () => { - expect(new CspConfig()).toMatchInlineSnapshot(` - CspConfig { - "header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", - "rules": Array [ - "script-src 'unsafe-eval' 'self'", - "worker-src blob: 'self'", - "style-src 'unsafe-inline' 'self'", - ], - "strict": true, - "warnLegacyBrowsers": true, - } - `); + expect(new CspConfig()).toEqual(CspConfig.DEFAULT); }); - test('creates from partial config', () => { - expect(new CspConfig({ strict: false, warnLegacyBrowsers: false })).toMatchInlineSnapshot(` - CspConfig { - "header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", - "rules": Array [ - "script-src 'unsafe-eval' 'self'", - "worker-src blob: 'self'", - "style-src 'unsafe-inline' 'self'", - ], - "strict": false, - "warnLegacyBrowsers": false, - } - `); - }); + describe('partial config', () => { + test('allows "rules" to be set and changes header', () => { + const rules = ['foo', 'bar']; + const config = new CspConfig({ rules }); + expect(config.rules).toEqual(rules); + expect(config.header).toMatchInlineSnapshot(`"foo; bar"`); + }); - test('computes header from rules', () => { - const cspConfig = new CspConfig({ rules: ['alpha', 'beta', 'gamma'] }); + test('allows "strict" to be set', () => { + const config = new CspConfig({ strict: false }); + expect(config.strict).toEqual(false); + expect(config.strict).not.toEqual(CspConfig.DEFAULT.strict); + }); - expect(cspConfig).toMatchInlineSnapshot(` - CspConfig { - "header": "alpha; beta; gamma", - "rules": Array [ - "alpha", - "beta", - "gamma", - ], - "strict": true, - "warnLegacyBrowsers": true, - } - `); + test('allows "warnLegacyBrowsers" to be set', () => { + const warnLegacyBrowsers = false; + const config = new CspConfig({ warnLegacyBrowsers }); + expect(config.warnLegacyBrowsers).toEqual(warnLegacyBrowsers); + expect(config.warnLegacyBrowsers).not.toEqual(CspConfig.DEFAULT.warnLegacyBrowsers); + }); + + 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 }); + expect(config.disableEmbedding).toEqual(disableEmbedding); + expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); + expect(config.rules).toEqual(expect.arrayContaining([FRAME_ANCESTORS_RULE])); + 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 }); + expect(config.disableEmbedding).toEqual(disableEmbedding); + expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); + expect(config.rules).toEqual(rules); + expect(config.header).toMatchInlineSnapshot(`"foo; bar"`); + }); + }); }); }); diff --git a/src/core/server/csp/csp_config.ts b/src/core/server/csp/csp_config.ts index dd0e7ef2dbee4..649c81576ef52 100644 --- a/src/core/server/csp/csp_config.ts +++ b/src/core/server/csp/csp_config.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { config } from './config'; +import { config, FRAME_ANCESTORS_RULE } from './config'; const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); @@ -32,6 +32,12 @@ export interface ICspConfig { */ readonly warnLegacyBrowsers: boolean; + /** + * Whether or not embedding (using iframes) should be allowed by the CSP. If embedding is disabled *and* no custom rules have been + * defined, a restrictive 'frame-ancestors' rule will be added to the default CSP rules. + */ + readonly disableEmbedding: boolean; + /** * The CSP rules in a formatted directives string for use * in a `Content-Security-Policy` header. @@ -49,6 +55,7 @@ export class CspConfig implements ICspConfig { public readonly rules: string[]; public readonly strict: boolean; public readonly warnLegacyBrowsers: boolean; + public readonly disableEmbedding: boolean; public readonly header: string; /** @@ -58,9 +65,13 @@ export class CspConfig implements ICspConfig { constructor(rawCspConfig: Partial> = {}) { const source = { ...DEFAULT_CONFIG, ...rawCspConfig }; - this.rules = source.rules; + this.rules = [...source.rules]; this.strict = source.strict; this.warnLegacyBrowsers = source.warnLegacyBrowsers; - this.header = source.rules.join('; '); + this.disableEmbedding = source.disableEmbedding; + if (!rawCspConfig.rules?.length && source.disableEmbedding) { + this.rules.push(FRAME_ANCESTORS_RULE); + } + this.header = this.rules.join('; '); } } diff --git a/src/core/server/environment/write_pid_file.ts b/src/core/server/environment/write_pid_file.ts index b7d47111a4d53..46096ca347e8a 100644 --- a/src/core/server/environment/write_pid_file.ts +++ b/src/core/server/environment/write_pid_file.ts @@ -31,13 +31,23 @@ export const writePidFile = async ({ if (pidConfig.exclusive) { throw new Error(message); } else { - logger.warn(message, { path, pid }); + logger.warn(message, { + process: { + pid: process.pid, + path, + }, + }); } } await writeFile(path, pid); - logger.debug(`wrote pid file to ${path}`, { path, pid }); + logger.debug(`wrote pid file to ${path}`, { + process: { + pid: process.pid, + path, + }, + }); const clean = once(() => { unlink(path); diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index 4545396c27b5e..42710aad40ac1 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -64,6 +64,14 @@ Object { "ipAllowlist": Array [], }, "rewriteBasePath": false, + "securityResponseHeaders": Object { + "disableEmbedding": false, + "permissionsPolicy": null, + "referrerPolicy": "no-referrer-when-downgrade", + "strictTransportSecurity": null, + "xContentTypeOptions": "nosniff", + }, + "shutdownTimeout": "PT30S", "socketTimeout": 120000, "ssl": Object { "cipherSuites": Array [ diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 9868d89888110..2a140388cc184 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -108,6 +108,35 @@ test('can specify max payload as string', () => { expect(configValue.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024); }); +describe('shutdownTimeout', () => { + test('can specify a valid shutdownTimeout', () => { + const configValue = config.schema.validate({ shutdownTimeout: '5s' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(5000); + }); + + test('can specify a valid shutdownTimeout (lower-edge of 1 second)', () => { + const configValue = config.schema.validate({ shutdownTimeout: '1s' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(1000); + }); + + test('can specify a valid shutdownTimeout (upper-edge of 2 minutes)', () => { + const configValue = config.schema.validate({ shutdownTimeout: '2m' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(120000); + }); + + test('should error if below 1s', () => { + expect(() => config.schema.validate({ shutdownTimeout: '100ms' })).toThrow( + '[shutdownTimeout]: the value should be between 1 second and 2 minutes' + ); + }); + + test('should error if over 2 minutes', () => { + expect(() => config.schema.validate({ shutdownTimeout: '3m' })).toThrow( + '[shutdownTimeout]: the value should be between 1 second and 2 minutes' + ); + }); +}); + describe('basePath', () => { test('throws if missing prepended slash', () => { const httpSchema = config.schema; diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index daf7424b8f8bd..9d0008e1c4011 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -11,9 +11,14 @@ import { IHttpConfig, SslConfig, sslSchema } from '@kbn/server-http-tools'; import { hostname } from 'os'; import url from 'url'; +import type { Duration } from 'moment'; import { ServiceConfigDescriptor } from '../internal_types'; import { CspConfigType, CspConfig, ICspConfig } from '../csp'; import { ExternalUrlConfig, IExternalUrlConfig } from '../external_url'; +import { + securityResponseHeadersSchema, + parseRawSecurityResponseHeadersConfig, +} from './security_response_headers_config'; const validBasePathRegex = /^\/.*[^\/]$/; const uuidRegexp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -31,6 +36,15 @@ const configSchema = schema.object( validate: match(validBasePathRegex, "must start with a slash, don't end with one"), }) ), + shutdownTimeout: schema.duration({ + defaultValue: '30s', + validate: (duration) => { + const durationMs = duration.asMilliseconds(); + if (durationMs < 1000 || durationMs > 2 * 60 * 1000) { + return 'the value should be between 1 second and 2 minutes'; + } + }, + }), cors: schema.object( { enabled: schema.boolean({ defaultValue: false }), @@ -53,6 +67,7 @@ const configSchema = schema.object( }, } ), + securityResponseHeaders: securityResponseHeadersSchema, customResponseHeaders: schema.recordOf(schema.string(), schema.any(), { defaultValue: {}, }), @@ -171,6 +186,7 @@ export class HttpConfig implements IHttpConfig { allowCredentials: boolean; allowOrigin: string[]; }; + public securityResponseHeaders: Record; public customResponseHeaders: Record; public maxPayload: ByteSizeValue; public basePath?: string; @@ -182,6 +198,7 @@ export class HttpConfig implements IHttpConfig { public externalUrl: IExternalUrlConfig; public xsrf: { disableProtection: boolean; allowlist: string[] }; public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] }; + public shutdownTimeout: Duration; /** * @internal @@ -195,6 +212,10 @@ export class HttpConfig implements IHttpConfig { this.host = rawHttpConfig.host; this.port = rawHttpConfig.port; this.cors = rawHttpConfig.cors; + const { securityResponseHeaders, disableEmbedding } = parseRawSecurityResponseHeadersConfig( + rawHttpConfig.securityResponseHeaders + ); + this.securityResponseHeaders = securityResponseHeaders; this.customResponseHeaders = Object.entries(rawHttpConfig.customResponseHeaders ?? {}).reduce( (headers, [key, value]) => { return { @@ -213,10 +234,11 @@ export class HttpConfig implements IHttpConfig { this.rewriteBasePath = rawHttpConfig.rewriteBasePath; this.ssl = new SslConfig(rawHttpConfig.ssl || {}); this.compression = rawHttpConfig.compression; - this.csp = new CspConfig(rawCspConfig); + this.csp = new CspConfig({ ...rawCspConfig, disableEmbedding }); this.externalUrl = rawExternalUrlConfig; this.xsrf = rawHttpConfig.xsrf; this.requestId = rawHttpConfig.requestId; + this.shutdownTimeout = rawHttpConfig.shutdownTimeout; } } diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index ccd14d4b99e11..1a82907849cea 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -26,6 +26,8 @@ import { HttpServer } from './http_server'; import { Readable } from 'stream'; import { RequestHandlerContext } from 'kibana/server'; import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; +import moment from 'moment'; +import { of } from 'rxjs'; const cookieOptions = { name: 'sid', @@ -65,6 +67,7 @@ beforeEach(() => { cors: { enabled: false, }, + shutdownTimeout: moment.duration(500, 'ms'), } as any; configWithSSL = { @@ -79,7 +82,7 @@ beforeEach(() => { }, } as HttpConfig; - server = new HttpServer(loggingService, 'tests'); + server = new HttpServer(loggingService, 'tests', of(config.shutdownTimeout)); }); afterEach(async () => { @@ -1431,3 +1434,79 @@ describe('setup contract', () => { }); }); }); + +describe('Graceful shutdown', () => { + let shutdownTimeout: number; + let innerServerListener: Server; + + beforeEach(async () => { + shutdownTimeout = config.shutdownTimeout.asMilliseconds(); + const { registerRouter, server: innerServer } = await server.setup(config); + innerServerListener = innerServer.listener; + + const router = new Router('', logger, enhanceWithContext); + router.post( + { + path: '/', + validate: false, + options: { body: { accepts: 'application/json' } }, + }, + async (context, req, res) => { + // It takes to resolve the same period of the shutdownTimeout. + // Since we'll trigger the stop a few ms after, it should have time to finish + await new Promise((resolve) => setTimeout(resolve, shutdownTimeout)); + return res.ok({ body: { ok: 1 } }); + } + ); + registerRouter(router); + + await server.start(); + }); + + test('any ongoing requests should be resolved with `connection: close`', async () => { + const [response] = await Promise.all([ + // Trigger a request that should hold the server from stopping until fulfilled + supertest(innerServerListener).post('/'), + // Stop the server while the request is in progress + (async () => { + await new Promise((resolve) => setTimeout(resolve, shutdownTimeout / 3)); + await server.stop(); + })(), + ]); + + expect(response.status).toBe(200); + expect(response.body).toStrictEqual({ ok: 1 }); + // The server is about to be closed, we need to ask connections to close on their end (stop their keep-alive policies) + expect(response.header.connection).toBe('close'); + }); + + test('any requests triggered while stopping should be rejected with 503', async () => { + const [, , response] = await Promise.all([ + // Trigger a request that should hold the server from stopping until fulfilled (otherwise the server will stop straight away) + supertest(innerServerListener).post('/'), + // Stop the server while the request is in progress + (async () => { + await new Promise((resolve) => setTimeout(resolve, shutdownTimeout / 3)); + await server.stop(); + })(), + // Trigger a new request while shutting down (should be rejected) + (async () => { + await new Promise((resolve) => setTimeout(resolve, (2 * shutdownTimeout) / 3)); + return supertest(innerServerListener).post('/'); + })(), + ]); + expect(response.status).toBe(503); + expect(response.body).toStrictEqual({ + statusCode: 503, + error: 'Service Unavailable', + message: 'Kibana is shutting down and not accepting new incoming requests', + }); + expect(response.header.connection).toBe('close'); + }); + + test('when no ongoing connections, the server should stop without waiting any longer', async () => { + const preStop = Date.now(); + await server.stop(); + expect(Date.now() - preStop).toBeLessThan(shutdownTimeout); + }); +}); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index cd7d7ccc5aeff..d845ac1b639b6 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -17,6 +17,9 @@ import { getRequestId, } from '@kbn/server-http-tools'; +import type { Duration } from 'moment'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; @@ -80,6 +83,7 @@ export class HttpServer { private authRegistered = false; private cookieSessionStorageCreated = false; private handleServerResponseEvent?: (req: Request) => void; + private stopping = false; private stopped = false; private readonly log: Logger; @@ -87,7 +91,11 @@ export class HttpServer { private readonly authRequestHeaders: AuthHeadersStorage; private readonly authResponseHeaders: AuthHeadersStorage; - constructor(private readonly logger: LoggerFactory, private readonly name: string) { + constructor( + private readonly logger: LoggerFactory, + private readonly name: string, + private readonly shutdownTimeout$: Observable + ) { this.authState = new AuthStateStorage(() => this.authRegistered); this.authRequestHeaders = new AuthHeadersStorage(); this.authResponseHeaders = new AuthHeadersStorage(); @@ -118,6 +126,7 @@ export class HttpServer { this.setupConditionalCompression(config); this.setupResponseLogging(); this.setupRequestStateAssignment(config); + this.setupGracefulShutdownHandlers(); return { registerRouter: this.registerRouter.bind(this), @@ -153,7 +162,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Http server is not setup up yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`start called after stop`); return; } @@ -213,19 +222,29 @@ export class HttpServer { } public async stop() { - this.stopped = true; + this.stopping = true; if (this.server === undefined) { + this.stopping = false; + this.stopped = true; return; } const hasStarted = this.server.info.started > 0; if (hasStarted) { this.log.debug('stopping http server'); + + const shutdownTimeout = await this.shutdownTimeout$.pipe(take(1)).toPromise(); + await this.server.stop({ timeout: shutdownTimeout.asMilliseconds() }); + + this.log.debug(`http server stopped`); + + // Removing the listener after stopping so we don't leave any pending requests unhandled if (this.handleServerResponseEvent) { this.server.events.removeListener('response', this.handleServerResponseEvent); } - await this.server.stop(); } + this.stopping = false; + this.stopped = true; } private getAuthOption( @@ -246,6 +265,18 @@ export class HttpServer { } } + private setupGracefulShutdownHandlers() { + this.registerOnPreRouting((request, response, toolkit) => { + if (this.stopping || this.stopped) { + return response.customError({ + statusCode: 503, + body: { message: 'Kibana is shutting down and not accepting new incoming requests' }, + }); + } + return toolkit.next(); + }); + } + private setupBasePathRewrite(config: HttpConfig, basePathService: BasePath) { if (config.basePath === undefined || !config.rewriteBasePath) { return; @@ -266,7 +297,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`setupConditionalCompression called after stop`); } @@ -296,14 +327,14 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`setupResponseLogging called after stop`); } const log = this.logger.get('http', 'server', 'response'); this.handleServerResponseEvent = (request) => { - const { message, ...meta } = getEcsResponseLog(request, this.log); + const { message, meta } = getEcsResponseLog(request, this.log); log.debug(message!, meta); }; @@ -325,7 +356,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`registerOnPreAuth called after stop`); } @@ -336,7 +367,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`registerOnPostAuth called after stop`); } @@ -347,7 +378,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`registerOnPreRouting called after stop`); } @@ -358,7 +389,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`registerOnPreResponse called after stop`); } @@ -372,7 +403,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`createCookieSessionStorageFactory called after stop`); } if (this.cookieSessionStorageCreated) { @@ -392,7 +423,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`registerAuth called after stop`); } if (this.authRegistered) { @@ -438,7 +469,7 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Http server is not setup up yet'); } - if (this.stopped) { + if (this.stopping || this.stopped) { this.log.warn(`registerStaticDir called after stop`); } diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 5b90440f6ad70..fdf9b738a9833 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Observable, Subscription, combineLatest } from 'rxjs'; +import { Observable, Subscription, combineLatest, of } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { Server } from '@hapi/hapi'; import { pick } from '@kbn/std'; @@ -69,7 +69,8 @@ export class HttpService configService.atPath(cspConfig.path), configService.atPath(externalUrlConfig.path), ]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); - this.httpServer = new HttpServer(logger, 'Kibana'); + const shutdownTimeout$ = this.config$.pipe(map(({ shutdownTimeout }) => shutdownTimeout)); + this.httpServer = new HttpServer(logger, 'Kibana', shutdownTimeout$); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } @@ -167,7 +168,7 @@ export class HttpService return; } - this.configSubscription.unsubscribe(); + this.configSubscription?.unsubscribe(); this.configSubscription = undefined; if (this.notReadyServer) { @@ -179,7 +180,7 @@ export class HttpService private async runNotReadyServer(config: HttpConfig) { this.log.debug('starting NotReady server'); - const httpServer = new HttpServer(this.logger, 'NotReady'); + const httpServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); const { server } = await httpServer.setup(config); this.notReadyServer = server; // use hapi server while KibanaResponseFactory doesn't allow specifying custom headers 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 2f9d393b632f7..cbd300fdc9c09 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -7,6 +7,7 @@ */ import supertest from 'supertest'; +import moment from 'moment'; import { BehaviorSubject } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; @@ -44,6 +45,7 @@ describe('core lifecycle handlers', () => { return new BehaviorSubject({ hosts: ['localhost'], maxPayload: new ByteSizeValue(1024), + shutdownTimeout: moment.duration(30, 'seconds'), autoListen: true, ssl: { enabled: false, @@ -53,8 +55,16 @@ describe('core lifecycle handlers', () => { }, compression: { enabled: true }, name: kibanaName, + securityResponseHeaders: { + // reflects default config + strictTransportSecurity: null, + xContentTypeOptions: 'nosniff', + referrerPolicy: 'strict-origin-when-cross-origin', + permissionsPolicy: null, + }, customResponseHeaders: { 'some-header': 'some-value', + 'referrer-policy': 'strict-origin', // overrides a header that is defined by securityResponseHeaders }, xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, requestId: { @@ -117,6 +127,13 @@ describe('core lifecycle handlers', () => { const testRoute = '/custom_headers/test/route'; const testErrorRoute = '/custom_headers/test/error_route'; + const expectedHeaders = { + [nameHeader]: kibanaName, + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'strict-origin', + 'some-header': 'some-value', + }; + beforeEach(async () => { router.get({ path: testRoute, validate: false }, (context, req, res) => { return res.ok({ body: 'ok' }); @@ -127,36 +144,16 @@ describe('core lifecycle handlers', () => { await server.start(); }); - it('adds the kbn-name header', async () => { - const result = await supertest(innerServer.listener).get(testRoute).expect(200, 'ok'); - const headers = result.header as Record; - expect(headers).toEqual( - expect.objectContaining({ - [nameHeader]: kibanaName, - }) - ); - }); - - it('adds the kbn-name header in case of error', async () => { - const result = await supertest(innerServer.listener).get(testErrorRoute).expect(400); - const headers = result.header as Record; - expect(headers).toEqual( - expect.objectContaining({ - [nameHeader]: kibanaName, - }) - ); - }); - - it('adds the custom headers', async () => { + it('adds the expected headers in case of success', async () => { const result = await supertest(innerServer.listener).get(testRoute).expect(200, 'ok'); const headers = result.header as Record; - expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' })); + expect(headers).toEqual(expect.objectContaining(expectedHeaders)); }); - it('adds the custom headers in case of error', async () => { + it('adds the expected headers in case of error', async () => { const result = await supertest(innerServer.listener).get(testErrorRoute).expect(400); const headers = result.header as Record; - expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' })); + expect(headers).toEqual(expect.objectContaining(expectedHeaders)); }); }); diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts index cd8caa7c76ab1..e777cbb1c1ff0 100644 --- a/src/core/server/http/lifecycle_handlers.test.ts +++ b/src/core/server/http/lifecycle_handlers.test.ts @@ -241,12 +241,15 @@ describe('customHeaders pre-response handler', () => { expect(toolkit.next).toHaveBeenCalledWith({ headers: { 'kbn-name': 'my-server-name' } }); }); - it('adds the custom headers defined in the configuration', () => { + it('adds the security headers and custom headers defined in the configuration', () => { const config = createConfig({ name: 'my-server-name', - customResponseHeaders: { + securityResponseHeaders: { headerA: 'value-A', - headerB: 'value-B', + headerB: 'value-B', // will be overridden by the custom response header below + }, + customResponseHeaders: { + headerB: 'x', }, }); const handler = createCustomHeadersPreResponseHandler(config as HttpConfig); @@ -258,7 +261,7 @@ describe('customHeaders pre-response handler', () => { headers: { 'kbn-name': 'my-server-name', headerA: 'value-A', - headerB: 'value-B', + headerB: 'x', }, }); }); diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts index a1fae89b68e11..eed24c8071eaf 100644 --- a/src/core/server/http/lifecycle_handlers.ts +++ b/src/core/server/http/lifecycle_handlers.ts @@ -62,12 +62,12 @@ export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPost }; export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPreResponseHandler => { - const serverName = config.name; - const customHeaders = config.customResponseHeaders; + const { name: serverName, securityResponseHeaders, customResponseHeaders } = config; return (request, response, toolkit) => { const additionalHeaders = { - ...customHeaders, + ...securityResponseHeaders, + ...customResponseHeaders, [KIBANA_NAME_HEADER]: serverName, }; diff --git a/src/core/server/http/logging/get_response_log.test.ts b/src/core/server/http/logging/get_response_log.test.ts index 64241ff44fc6b..5f749220138d7 100644 --- a/src/core/server/http/logging/get_response_log.test.ts +++ b/src/core/server/http/logging/get_response_log.test.ts @@ -81,7 +81,8 @@ describe('getEcsResponseLog', () => { }, }); const result = getEcsResponseLog(req, logger); - expect(result.http.response.responseTime).toBe(1000); + // @ts-expect-error ECS custom field + expect(result.meta.http.response.responseTime).toBe(1000); }); test('with response.info.responded', () => { @@ -92,14 +93,16 @@ describe('getEcsResponseLog', () => { }, }); const result = getEcsResponseLog(req, logger); - expect(result.http.response.responseTime).toBe(500); + // @ts-expect-error ECS custom field + expect(result.meta.http.response.responseTime).toBe(500); }); test('excludes responseTime from message if none is provided', () => { const req = createMockHapiRequest(); const result = getEcsResponseLog(req, logger); expect(result.message).toMatchInlineSnapshot(`"GET /path 200 - 1.2KB"`); - expect(result.http.response.responseTime).toBeUndefined(); + // @ts-expect-error ECS custom field + expect(result.meta.http.response.responseTime).toBeUndefined(); }); }); @@ -112,7 +115,7 @@ describe('getEcsResponseLog', () => { }, }); const result = getEcsResponseLog(req, logger); - expect(result.url.query).toMatchInlineSnapshot(`"a=hello&b=world"`); + expect(result.meta.url!.query).toMatchInlineSnapshot(`"a=hello&b=world"`); expect(result.message).toMatchInlineSnapshot(`"GET /path?a=hello&b=world 200 - 1.2KB"`); }); @@ -121,7 +124,7 @@ describe('getEcsResponseLog', () => { query: { a: '¡hola!' }, }); const result = getEcsResponseLog(req, logger); - expect(result.url.query).toMatchInlineSnapshot(`"a=%C2%A1hola!"`); + expect(result.meta.url!.query).toMatchInlineSnapshot(`"a=%C2%A1hola!"`); expect(result.message).toMatchInlineSnapshot(`"GET /path?a=%C2%A1hola! 200 - 1.2KB"`); }); }); @@ -145,7 +148,7 @@ describe('getEcsResponseLog', () => { response: Boom.badRequest(), }); const result = getEcsResponseLog(req, logger); - expect(result.http.response.status_code).toBe(400); + expect(result.meta.http!.response!.status_code).toBe(400); }); describe('filters sensitive headers', () => { @@ -155,14 +158,16 @@ describe('getEcsResponseLog', () => { response: { headers: { 'content-length': 123, 'set-cookie': 'c' } }, }); const result = getEcsResponseLog(req, logger); - expect(result.http.request.headers).toMatchInlineSnapshot(` + // @ts-expect-error ECS custom field + expect(result.meta.http.request.headers).toMatchInlineSnapshot(` Object { "authorization": "[REDACTED]", "cookie": "[REDACTED]", "user-agent": "hi", } `); - expect(result.http.response.headers).toMatchInlineSnapshot(` + // @ts-expect-error ECS custom field + expect(result.meta.http.response.headers).toMatchInlineSnapshot(` Object { "content-length": 123, "set-cookie": "[REDACTED]", @@ -196,9 +201,12 @@ describe('getEcsResponseLog', () => { } `); - responseLog.http.request.headers.a = 'testA'; - responseLog.http.request.headers.b[1] = 'testB'; - responseLog.http.request.headers.c = 'testC'; + // @ts-expect-error ECS custom field + responseLog.meta.http.request.headers.a = 'testA'; + // @ts-expect-error ECS custom field + responseLog.meta.http.request.headers.b[1] = 'testB'; + // @ts-expect-error ECS custom field + responseLog.meta.http.request.headers.c = 'testC'; expect(reqHeaders).toMatchInlineSnapshot(` Object { "a": "foo", @@ -244,48 +252,41 @@ describe('getEcsResponseLog', () => { }); describe('ecs', () => { - test('specifies correct ECS version', () => { - const req = createMockHapiRequest(); - const result = getEcsResponseLog(req, logger); - expect(result.ecs.version).toBe('1.7.0'); - }); - test('provides an ECS-compatible response', () => { const req = createMockHapiRequest(); const result = getEcsResponseLog(req, logger); expect(result).toMatchInlineSnapshot(` Object { - "client": Object { - "ip": undefined, - }, - "ecs": Object { - "version": "1.7.0", - }, - "http": Object { - "request": Object { - "headers": Object { - "user-agent": "", - }, - "method": "GET", - "mime_type": "application/json", - "referrer": "localhost:5601/app/home", + "message": "GET /path 200 - 1.2KB", + "meta": Object { + "client": Object { + "ip": undefined, }, - "response": Object { - "body": Object { - "bytes": 1234, + "http": Object { + "request": Object { + "headers": Object { + "user-agent": "", + }, + "method": "GET", + "mime_type": "application/json", + "referrer": "localhost:5601/app/home", + }, + "response": Object { + "body": Object { + "bytes": 1234, + }, + "headers": Object {}, + "responseTime": undefined, + "status_code": 200, }, - "headers": Object {}, - "responseTime": undefined, - "status_code": 200, }, - }, - "message": "GET /path 200 - 1.2KB", - "url": Object { - "path": "/path", - "query": "", - }, - "user_agent": Object { - "original": "", + "url": Object { + "path": "/path", + "query": "", + }, + "user_agent": Object { + "original": "", + }, }, } `); diff --git a/src/core/server/http/logging/get_response_log.ts b/src/core/server/http/logging/get_response_log.ts index 57c02e05bebff..37ee618e43395 100644 --- a/src/core/server/http/logging/get_response_log.ts +++ b/src/core/server/http/logging/get_response_log.ts @@ -11,10 +11,9 @@ import { isBoom } from '@hapi/boom'; import type { Request } from '@hapi/hapi'; import numeral from '@elastic/numeral'; import { LogMeta } from '@kbn/logging'; -import { EcsEvent, Logger } from '../../logging'; +import { Logger } from '../../logging'; import { getResponsePayloadBytes } from './get_payload_size'; -const ECS_VERSION = '1.7.0'; const FORBIDDEN_HEADERS = ['authorization', 'cookie', 'set-cookie']; const REDACTED_HEADER_TEXT = '[REDACTED]'; @@ -44,7 +43,7 @@ function cloneAndFilterHeaders(headers?: HapiHeaders) { * * @internal */ -export function getEcsResponseLog(request: Request, log: Logger): LogMeta { +export function getEcsResponseLog(request: Request, log: Logger) { const { path, response } = request; const method = request.method.toUpperCase(); @@ -66,9 +65,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { const bytes = getResponsePayloadBytes(response, log); const bytesMsg = bytes ? ` - ${numeral(bytes).format('0.0b')}` : ''; - const meta: EcsEvent = { - ecs: { version: ECS_VERSION }, - message: `${method} ${pathWithQuery} ${status_code}${responseTimeMsg}${bytesMsg}`, + const meta: LogMeta = { client: { ip: request.info.remoteAddress, }, @@ -77,7 +74,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { method, mime_type: request.mime, referrer: request.info.referrer, - // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232. + // @ts-expect-error ECS custom field: https://github.com/elastic/ecs/issues/232. headers: requestHeaders, }, response: { @@ -85,7 +82,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { bytes, }, status_code, - // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232. + // @ts-expect-error ECS custom field: https://github.com/elastic/ecs/issues/232. headers: responseHeaders, // responseTime is a custom non-ECS field responseTime: !isNaN(responseTime) ? responseTime : undefined, @@ -100,5 +97,8 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { }, }; - return meta; + return { + message: `${method} ${pathWithQuery} ${status_code}${responseTimeMsg}${bytesMsg}`, + meta, + }; } diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 77b40ca5995bb..ea70f1b4f543b 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -70,7 +70,7 @@ export interface RouteConfigOptionsBody { /** * Limits the size of incoming payloads to the specified byte count. Allowing very large payloads may cause the server to run out of memory. * - * Default value: The one set in the kibana.yml config file under the parameter `server.maxPayloadBytes`. + * Default value: The one set in the kibana.yml config file under the parameter `server.maxPayload`. */ maxBytes?: number; diff --git a/src/core/server/http/security_response_headers_config.test.ts b/src/core/server/http/security_response_headers_config.test.ts new file mode 100644 index 0000000000000..b1c8bb23102f5 --- /dev/null +++ b/src/core/server/http/security_response_headers_config.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + securityResponseHeadersSchema as schema, + parseRawSecurityResponseHeadersConfig as parse, +} from './security_response_headers_config'; + +describe('parseRawSecurityResponseHeadersConfig', () => { + it('returns default values', () => { + const config = schema.validate({}); + const result = parse(config); + expect(result.disableEmbedding).toBe(false); + expect(result.securityResponseHeaders).toMatchInlineSnapshot(` + Object { + "Referrer-Policy": "no-referrer-when-downgrade", + "X-Content-Type-Options": "nosniff", + } + `); + }); + + describe('strictTransportSecurity', () => { + it('a custom value results in the expected Strict-Transport-Security header', () => { + const strictTransportSecurity = 'max-age=31536000; includeSubDomains'; + const config = schema.validate({ strictTransportSecurity }); + const result = parse(config); + expect(result.securityResponseHeaders['Strict-Transport-Security']).toEqual( + strictTransportSecurity + ); + }); + + it('a null value removes the Strict-Transport-Security header', () => { + const config = schema.validate({ strictTransportSecurity: null }); + const result = parse(config); + expect(result.securityResponseHeaders['Strict-Transport-Security']).toBeUndefined(); + }); + }); + + describe('xContentTypeOptions', () => { + it('a custom value results in the expected X-Content-Type-Options header', () => { + const xContentTypeOptions = 'nosniff'; // there is no other valid value to test with + const config = schema.validate({ xContentTypeOptions }); + const result = parse(config); + expect(result.securityResponseHeaders['X-Content-Type-Options']).toEqual(xContentTypeOptions); + }); + + it('a null value removes the X-Content-Type-Options header', () => { + const config = schema.validate({ xContentTypeOptions: null }); + const result = parse(config); + expect(result.securityResponseHeaders['X-Content-Type-Options']).toBeUndefined(); + }); + }); + + describe('referrerPolicy', () => { + it('a custom value results in the expected Referrer-Policy header', () => { + const referrerPolicy = 'strict-origin-when-cross-origin'; + const config = schema.validate({ referrerPolicy }); + const result = parse(config); + expect(result.securityResponseHeaders['Referrer-Policy']).toEqual(referrerPolicy); + }); + + it('a null value removes the Referrer-Policy header', () => { + const config = schema.validate({ referrerPolicy: null }); + const result = parse(config); + expect(result.securityResponseHeaders['Referrer-Policy']).toBeUndefined(); + }); + }); + + describe('permissionsPolicy', () => { + it('a custom value results in the expected Permissions-Policy header', () => { + const permissionsPolicy = 'display-capture=(self)'; + const config = schema.validate({ permissionsPolicy }); + const result = parse(config); + expect(result.securityResponseHeaders['Permissions-Policy']).toEqual(permissionsPolicy); + }); + + it('a null value removes the Permissions-Policy header', () => { + const config = schema.validate({ permissionsPolicy: null }); + const result = parse(config); + expect(result.securityResponseHeaders['Permissions-Policy']).toBeUndefined(); + }); + }); + + describe('disableEmbedding', () => { + it('a true value results in the expected X-Frame-Options header and expected disableEmbedding result value', () => { + const config = schema.validate({ disableEmbedding: true }); + const result = parse(config); + expect(result.securityResponseHeaders['X-Frame-Options']).toMatchInlineSnapshot( + `"SAMEORIGIN"` + ); + expect(result.disableEmbedding).toBe(true); + }); + }); +}); diff --git a/src/core/server/http/security_response_headers_config.ts b/src/core/server/http/security_response_headers_config.ts new file mode 100644 index 0000000000000..917d737d59297 --- /dev/null +++ b/src/core/server/http/security_response_headers_config.ts @@ -0,0 +1,72 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const securityResponseHeadersSchema = schema.object({ + strictTransportSecurity: schema.oneOf([schema.string(), schema.literal(null)], { + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security + defaultValue: null, + }), + xContentTypeOptions: schema.oneOf([schema.literal('nosniff'), schema.literal(null)], { + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + defaultValue: 'nosniff', + }), + referrerPolicy: schema.oneOf( + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy + [ + schema.literal('no-referrer'), + schema.literal('no-referrer-when-downgrade'), + schema.literal('origin'), + schema.literal('origin-when-cross-origin'), + schema.literal('same-origin'), + schema.literal('strict-origin'), + schema.literal('strict-origin-when-cross-origin'), + schema.literal('unsafe-url'), + schema.literal(null), + ], + { defaultValue: 'no-referrer-when-downgrade' } + ), + permissionsPolicy: schema.oneOf([schema.string(), schema.literal(null)], { + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy + // Note: Feature-Policy is superseded by Permissions-Policy; the link above is temporary until MDN releases an updated page + defaultValue: null, + }), + disableEmbedding: schema.boolean({ defaultValue: false }), // is used to control X-Frame-Options and CSP headers +}); + +/** + * Parses raw security header config info, returning an object with the appropriate header keys and values. + * + * @param raw + * @internal + */ +export function parseRawSecurityResponseHeadersConfig( + raw: TypeOf +) { + const securityResponseHeaders: Record = {}; + const { disableEmbedding } = raw; + + if (raw.strictTransportSecurity) { + securityResponseHeaders['Strict-Transport-Security'] = raw.strictTransportSecurity; + } + if (raw.xContentTypeOptions) { + securityResponseHeaders['X-Content-Type-Options'] = raw.xContentTypeOptions; + } + if (raw.referrerPolicy) { + securityResponseHeaders['Referrer-Policy'] = raw.referrerPolicy; + } + if (raw.permissionsPolicy) { + securityResponseHeaders['Permissions-Policy'] = raw.permissionsPolicy; + } + if (disableEmbedding) { + securityResponseHeaders['X-Frame-Options'] = 'SAMEORIGIN'; + } + + return { securityResponseHeaders, disableEmbedding }; +} diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index b9b877e193fbd..b3180b43d0026 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -7,6 +7,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import moment from 'moment'; import { REPO_ROOT } from '@kbn/dev-utils'; import { ByteSizeValue } from '@kbn/config-schema'; import { Env } from '../config'; @@ -38,11 +39,13 @@ configService.atPath.mockImplementation((path) => { disableProtection: true, allowlist: [], }, + securityResponseHeaders: {}, customResponseHeaders: {}, requestId: { allowFromAnyIp: true, ipAllowlist: [], }, + shutdownTimeout: moment.duration(30, 'seconds'), keepaliveTimeout: 120_000, socketTimeout: 120_000, } as any); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 2c6fa74cb54a0..9fccc4b8bc1f0 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -64,6 +64,7 @@ import { CoreUsageStats, CoreUsageData, CoreConfigUsageData, + ConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData, } from './core_usage_data'; @@ -74,6 +75,7 @@ export type { CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData, + ConfigUsageData, }; export { bootstrap } from './bootstrap'; @@ -236,6 +238,11 @@ export type { IRenderOptions } from './rendering'; export type { Logger, LoggerFactory, + Ecs, + EcsEventCategory, + EcsEventKind, + EcsEventOutcome, + EcsEventType, LogMeta, LogRecord, LogLevel, @@ -256,6 +263,7 @@ export type { PluginManifest, PluginName, SharedGlobalConfig, + MakeUsageFromSchema, } from './plugins'; export { diff --git a/src/core/server/kibana_config.ts b/src/core/server/kibana_config.ts index 97783a7657db5..848c51dcb69f3 100644 --- a/src/core/server/kibana_config.ts +++ b/src/core/server/kibana_config.ts @@ -33,4 +33,8 @@ export const config = { autocompleteTimeout: schema.duration({ defaultValue: 1000 }), }), deprecations, + exposeToUsage: { + autocompleteTerminateAfter: true, + autocompleteTimeout: true, + }, }; diff --git a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap index 81321a3b1fe44..d74317203d78e 100644 --- a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap +++ b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap @@ -15,6 +15,9 @@ exports[`appends records via multiple appenders.: file logs 2`] = ` exports[`asLoggerFactory() only allows to create new loggers. 1`] = ` Object { "@timestamp": "2012-01-30T22:33:22.011-05:00", + "ecs": Object { + "version": "1.9.0", + }, "log": Object { "level": "TRACE", "logger": "test.context", @@ -29,6 +32,9 @@ Object { exports[`asLoggerFactory() only allows to create new loggers. 2`] = ` Object { "@timestamp": "2012-01-30T17:33:22.011-05:00", + "ecs": Object { + "version": "1.9.0", + }, "log": Object { "level": "INFO", "logger": "test.context", @@ -44,6 +50,9 @@ Object { exports[`asLoggerFactory() only allows to create new loggers. 3`] = ` Object { "@timestamp": "2012-01-30T12:33:22.011-05:00", + "ecs": Object { + "version": "1.9.0", + }, "log": Object { "level": "FATAL", "logger": "test.context", @@ -58,6 +67,9 @@ Object { exports[`flushes memory buffer logger and switches to real logger once config is provided: buffered messages 1`] = ` Object { "@timestamp": "2012-02-01T09:33:22.011-05:00", + "ecs": Object { + "version": "1.9.0", + }, "log": Object { "level": "INFO", "logger": "test.context", @@ -73,6 +85,9 @@ Object { exports[`flushes memory buffer logger and switches to real logger once config is provided: new messages 1`] = ` Object { "@timestamp": "2012-01-31T23:33:22.011-05:00", + "ecs": Object { + "version": "1.9.0", + }, "log": Object { "level": "INFO", "logger": "test.context", diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts index 52b88331a75be..faa026363ed40 100644 --- a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts +++ b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts @@ -26,12 +26,14 @@ describe('MetaRewritePolicy', () => { describe('mode: update', () => { it('updates existing properties in LogMeta', () => { + // @ts-expect-error ECS custom meta const log = createLogRecord({ a: 'before' }); const policy = createPolicy('update', [{ path: 'a', value: 'after' }]); expect(policy.rewrite(log).meta!.a).toBe('after'); }); it('updates nested properties in LogMeta', () => { + // @ts-expect-error ECS custom meta const log = createLogRecord({ a: 'before a', b: { c: 'before b.c' }, d: [0, 1] }); const policy = createPolicy('update', [ { path: 'a', value: 'after a' }, @@ -60,6 +62,7 @@ describe('MetaRewritePolicy', () => { { path: 'd', value: 'hi' }, ]); const log = createLogRecord({ + // @ts-expect-error ECS custom meta a: 'a', b: 'b', c: 'c', @@ -80,6 +83,7 @@ describe('MetaRewritePolicy', () => { { path: 'a.b', value: 'foo' }, { path: 'a.c', value: 'bar' }, ]); + // @ts-expect-error ECS custom meta const log = createLogRecord({ a: { b: 'existing meta' } }); const { meta } = policy.rewrite(log); expect(meta!.a.b).toBe('foo'); @@ -106,12 +110,14 @@ describe('MetaRewritePolicy', () => { describe('mode: remove', () => { it('removes existing properties in LogMeta', () => { + // @ts-expect-error ECS custom meta const log = createLogRecord({ a: 'goodbye' }); const policy = createPolicy('remove', [{ path: 'a' }]); expect(policy.rewrite(log).meta!.a).toBeUndefined(); }); it('removes nested properties in LogMeta', () => { + // @ts-expect-error ECS custom meta const log = createLogRecord({ a: 'a', b: { c: 'b.c' }, d: [0, 1] }); const policy = createPolicy('remove', [{ path: 'b.c' }, { path: 'd[1]' }]); expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` @@ -127,6 +133,7 @@ describe('MetaRewritePolicy', () => { }); it('has no effect if property does not exist', () => { + // @ts-expect-error ECS custom meta const log = createLogRecord({ a: 'a' }); const policy = createPolicy('remove', [{ path: 'b' }]); expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts index 72a54b5012ce5..f4ce64ee65075 100644 --- a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts +++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts @@ -85,8 +85,8 @@ describe('RewriteAppender', () => { const appender = new RewriteAppender(config); appenderMocks.forEach((mock) => appender.addAppender(...mock)); - const log1 = createLogRecord({ a: 'b' }); - const log2 = createLogRecord({ c: 'd' }); + const log1 = createLogRecord({ user_agent: { name: 'a' } }); + const log2 = createLogRecord({ user_agent: { name: 'b' } }); appender.append(log1); @@ -109,8 +109,8 @@ describe('RewriteAppender', () => { const appender = new RewriteAppender(config); appender.addAppender(...createAppenderMock('mock1')); - const log1 = createLogRecord({ a: 'b' }); - const log2 = createLogRecord({ c: 'd' }); + const log1 = createLogRecord({ user_agent: { name: 'a' } }); + const log2 = createLogRecord({ user_agent: { name: 'b' } }); appender.append(log1); diff --git a/src/core/server/logging/ecs.ts b/src/core/server/logging/ecs.ts deleted file mode 100644 index f6db79819d819..0000000000000 --- a/src/core/server/logging/ecs.ts +++ /dev/null @@ -1,129 +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. - */ - -/** - * Typings for some ECS fields which core uses internally. - * These are not a complete set of ECS typings and should not - * be used externally; the only types included here are ones - * currently used in core. - * - * @internal - */ -export interface EcsEvent { - /** - * These typings were written as of ECS 1.7.0. - * Don't change this value without checking the rest - * of the types to conform to that ECS version. - * - * https://www.elastic.co/guide/en/ecs/1.7/index.html - */ - ecs: { version: '1.7.0' }; - - // base fields - ['@timestamp']?: string; - labels?: Record; - message?: string; - tags?: string[]; - - // other fields - client?: EcsClientField; - event?: EcsEventField; - http?: EcsHttpField; - process?: EcsProcessField; - url?: EcsUrlField; - user_agent?: EcsUserAgentField; -} - -/** @internal */ -export enum EcsEventKind { - ALERT = 'alert', - EVENT = 'event', - METRIC = 'metric', - STATE = 'state', - PIPELINE_ERROR = 'pipeline_error', - SIGNAL = 'signal', -} - -/** @internal */ -export enum EcsEventCategory { - AUTHENTICATION = 'authentication', - CONFIGURATION = 'configuration', - DATABASE = 'database', - DRIVER = 'driver', - FILE = 'file', - HOST = 'host', - IAM = 'iam', - INTRUSION_DETECTION = 'intrusion_detection', - MALWARE = 'malware', - NETWORK = 'network', - PACKAGE = 'package', - PROCESS = 'process', - WEB = 'web', -} - -/** @internal */ -export enum EcsEventType { - ACCESS = 'access', - ADMIN = 'admin', - ALLOWED = 'allowed', - CHANGE = 'change', - CONNECTION = 'connection', - CREATION = 'creation', - DELETION = 'deletion', - DENIED = 'denied', - END = 'end', - ERROR = 'error', - GROUP = 'group', - INFO = 'info', - INSTALLATION = 'installation', - PROTOCOL = 'protocol', - START = 'start', - USER = 'user', -} - -interface EcsEventField { - kind?: EcsEventKind; - category?: EcsEventCategory[]; - type?: EcsEventType; -} - -interface EcsProcessField { - uptime?: number; -} - -interface EcsClientField { - ip?: string; -} - -interface EcsHttpFieldRequest { - body?: { bytes?: number; content?: string }; - method?: string; - mime_type?: string; - referrer?: string; -} - -interface EcsHttpFieldResponse { - body?: { bytes?: number; content?: string }; - bytes?: number; - status_code?: number; -} - -interface EcsHttpField { - version?: string; - request?: EcsHttpFieldRequest; - response?: EcsHttpFieldResponse; -} - -interface EcsUrlField { - path?: string; - query?: string; -} - -interface EcsUserAgentField { - original?: string; -} diff --git a/src/core/server/logging/index.ts b/src/core/server/logging/index.ts index cef96be54870e..9d17b289bfa4c 100644 --- a/src/core/server/logging/index.ts +++ b/src/core/server/logging/index.ts @@ -9,6 +9,11 @@ export { LogLevel } from '@kbn/logging'; export type { DisposableAppender, Appender, + Ecs, + EcsEventCategory, + EcsEventKind, + EcsEventOutcome, + EcsEventType, LogRecord, Layout, LoggerFactory, @@ -16,8 +21,6 @@ export type { Logger, LogLevelId, } from '@kbn/logging'; -export { EcsEventType, EcsEventCategory, EcsEventKind } from './ecs'; -export type { EcsEvent } from './ecs'; export { config } from './logging_config'; export type { LoggingConfigType, diff --git a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap index 0e7ce8d0b2f3c..a131d5c8a9248 100644 --- a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap +++ b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`\`format()\` correctly formats record. 1`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"type\\":\\"Some error name\\",\\"stack_trace\\":\\"Some error stack\\"},\\"log\\":{\\"level\\":\\"FATAL\\",\\"logger\\":\\"context-1\\"},\\"process\\":{\\"pid\\":5355}}"`; +exports[`\`format()\` correctly formats record. 1`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"type\\":\\"Some error name\\",\\"stack_trace\\":\\"Some error stack\\"},\\"log\\":{\\"level\\":\\"FATAL\\",\\"logger\\":\\"context-1\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 2`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-2\\",\\"log\\":{\\"level\\":\\"ERROR\\",\\"logger\\":\\"context-2\\"},\\"process\\":{\\"pid\\":5355}}"`; +exports[`\`format()\` correctly formats record. 2`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-2\\",\\"log\\":{\\"level\\":\\"ERROR\\",\\"logger\\":\\"context-2\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 3`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-3\\",\\"log\\":{\\"level\\":\\"WARN\\",\\"logger\\":\\"context-3\\"},\\"process\\":{\\"pid\\":5355}}"`; +exports[`\`format()\` correctly formats record. 3`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-3\\",\\"log\\":{\\"level\\":\\"WARN\\",\\"logger\\":\\"context-3\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 4`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-4\\",\\"log\\":{\\"level\\":\\"DEBUG\\",\\"logger\\":\\"context-4\\"},\\"process\\":{\\"pid\\":5355}}"`; +exports[`\`format()\` correctly formats record. 4`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-4\\",\\"log\\":{\\"level\\":\\"DEBUG\\",\\"logger\\":\\"context-4\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 5`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-5\\",\\"log\\":{\\"level\\":\\"INFO\\",\\"logger\\":\\"context-5\\"},\\"process\\":{\\"pid\\":5355}}"`; +exports[`\`format()\` correctly formats record. 5`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-5\\",\\"log\\":{\\"level\\":\\"INFO\\",\\"logger\\":\\"context-5\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 6`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-6\\",\\"log\\":{\\"level\\":\\"TRACE\\",\\"logger\\":\\"context-6\\"},\\"process\\":{\\"pid\\":5355}}"`; +exports[`\`format()\` correctly formats record. 6`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-6\\",\\"log\\":{\\"level\\":\\"TRACE\\",\\"logger\\":\\"context-6\\"},\\"process\\":{\\"pid\\":5355}}"`; diff --git a/src/core/server/logging/layouts/json_layout.test.ts b/src/core/server/logging/layouts/json_layout.test.ts index e55f69daab110..e76e3fb4402bb 100644 --- a/src/core/server/logging/layouts/json_layout.test.ts +++ b/src/core/server/logging/layouts/json_layout.test.ts @@ -94,6 +94,7 @@ test('`format()` correctly formats record with meta-data', () => { }) ) ).toStrictEqual({ + ecs: { version: '1.9.0' }, '@timestamp': '2012-02-01T09:30:22.011-05:00', log: { level: 'DEBUG', @@ -135,6 +136,7 @@ test('`format()` correctly formats error record with meta-data', () => { }) ) ).toStrictEqual({ + ecs: { version: '1.9.0' }, '@timestamp': '2012-02-01T09:30:22.011-05:00', log: { level: 'DEBUG', @@ -156,7 +158,39 @@ test('`format()` correctly formats error record with meta-data', () => { }); }); -test('format() meta can override @timestamp', () => { +test('format() meta can merge override logs', () => { + const layout = new JsonLayout(); + expect( + JSON.parse( + layout.format({ + timestamp, + message: 'foo', + level: LogLevel.Error, + context: 'bar', + pid: 3, + meta: { + log: { + kbn_custom_field: 'hello', + }, + }, + }) + ) + ).toStrictEqual({ + ecs: { version: '1.9.0' }, + '@timestamp': '2012-02-01T09:30:22.011-05:00', + message: 'foo', + log: { + level: 'ERROR', + logger: 'bar', + kbn_custom_field: 'hello', + }, + process: { + pid: 3, + }, + }); +}); + +test('format() meta can not override message', () => { const layout = new JsonLayout(); expect( JSON.parse( @@ -167,12 +201,13 @@ test('format() meta can override @timestamp', () => { context: 'bar', pid: 3, meta: { - '@timestamp': '2099-05-01T09:30:22.011-05:00', + message: 'baz', }, }) ) ).toStrictEqual({ - '@timestamp': '2099-05-01T09:30:22.011-05:00', + ecs: { version: '1.9.0' }, + '@timestamp': '2012-02-01T09:30:22.011-05:00', message: 'foo', log: { level: 'DEBUG', @@ -184,30 +219,60 @@ test('format() meta can override @timestamp', () => { }); }); -test('format() meta can merge override logs', () => { +test('format() meta can not override ecs version', () => { const layout = new JsonLayout(); expect( JSON.parse( layout.format({ + message: 'foo', timestamp, + level: LogLevel.Debug, + context: 'bar', + pid: 3, + meta: { + message: 'baz', + }, + }) + ) + ).toStrictEqual({ + ecs: { version: '1.9.0' }, + '@timestamp': '2012-02-01T09:30:22.011-05:00', + message: 'foo', + log: { + level: 'DEBUG', + logger: 'bar', + }, + process: { + pid: 3, + }, + }); +}); + +test('format() meta can not override logger or level', () => { + const layout = new JsonLayout(); + expect( + JSON.parse( + layout.format({ message: 'foo', - level: LogLevel.Error, + timestamp, + level: LogLevel.Debug, context: 'bar', pid: 3, meta: { log: { - kbn_custom_field: 'hello', + level: 'IGNORE', + logger: 'me', }, }, }) ) ).toStrictEqual({ + ecs: { version: '1.9.0' }, '@timestamp': '2012-02-01T09:30:22.011-05:00', message: 'foo', log: { - level: 'ERROR', + level: 'DEBUG', logger: 'bar', - kbn_custom_field: 'hello', }, process: { pid: 3, @@ -215,29 +280,28 @@ test('format() meta can merge override logs', () => { }); }); -test('format() meta can override log level objects', () => { +test('format() meta can not override timestamp', () => { const layout = new JsonLayout(); expect( JSON.parse( layout.format({ - timestamp, - context: '123', message: 'foo', - level: LogLevel.Error, + timestamp, + level: LogLevel.Debug, + context: 'bar', pid: 3, meta: { - log: { - level: 'FATAL', - }, + '@timestamp': '2099-02-01T09:30:22.011-05:00', }, }) ) ).toStrictEqual({ + ecs: { version: '1.9.0' }, '@timestamp': '2012-02-01T09:30:22.011-05:00', message: 'foo', log: { - level: 'FATAL', - logger: '123', + level: 'DEBUG', + logger: 'bar', }, process: { pid: 3, diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts index bb8423f8240af..add88cc01b6d2 100644 --- a/src/core/server/logging/layouts/json_layout.ts +++ b/src/core/server/logging/layouts/json_layout.ts @@ -9,7 +9,7 @@ import moment from 'moment-timezone'; import { merge } from '@kbn/std'; import { schema } from '@kbn/config-schema'; -import { LogRecord, Layout } from '@kbn/logging'; +import { Ecs, LogRecord, Layout } from '@kbn/logging'; const { literal, object } = schema; @@ -42,7 +42,8 @@ export class JsonLayout implements Layout { } public format(record: LogRecord): string { - const log = { + const log: Ecs = { + ecs: { version: '1.9.0' }, '@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), message: record.message, error: JsonLayout.errorToSerializableObject(record.error), @@ -54,7 +55,8 @@ export class JsonLayout implements Layout { pid: record.pid, }, }; - const output = record.meta ? merge(log, record.meta) : log; + const output = record.meta ? merge({ ...record.meta }, log) : log; + return JSON.stringify(output); } } diff --git a/src/core/server/logging/logger.test.ts b/src/core/server/logging/logger.test.ts index b7f224e73cb8b..c57ce2563ca3d 100644 --- a/src/core/server/logging/logger.test.ts +++ b/src/core/server/logging/logger.test.ts @@ -45,6 +45,7 @@ test('`trace()` correctly forms `LogRecord` and passes it to all appenders.', () }); } + // @ts-expect-error ECS custom meta logger.trace('message-2', { trace: true }); for (const appenderMock of appenderMocks) { expect(appenderMock.append).toHaveBeenCalledTimes(2); @@ -75,6 +76,7 @@ test('`debug()` correctly forms `LogRecord` and passes it to all appenders.', () }); } + // @ts-expect-error ECS custom meta logger.debug('message-2', { debug: true }); for (const appenderMock of appenderMocks) { expect(appenderMock.append).toHaveBeenCalledTimes(2); @@ -105,6 +107,7 @@ test('`info()` correctly forms `LogRecord` and passes it to all appenders.', () }); } + // @ts-expect-error ECS custom meta logger.info('message-2', { info: true }); for (const appenderMock of appenderMocks) { expect(appenderMock.append).toHaveBeenCalledTimes(2); @@ -150,6 +153,7 @@ test('`warn()` correctly forms `LogRecord` and passes it to all appenders.', () }); } + // @ts-expect-error ECS custom meta logger.warn('message-3', { warn: true }); for (const appenderMock of appenderMocks) { expect(appenderMock.append).toHaveBeenCalledTimes(3); @@ -195,6 +199,7 @@ test('`error()` correctly forms `LogRecord` and passes it to all appenders.', () }); } + // @ts-expect-error ECS custom meta logger.error('message-3', { error: true }); for (const appenderMock of appenderMocks) { expect(appenderMock.append).toHaveBeenCalledTimes(3); @@ -240,6 +245,7 @@ test('`fatal()` correctly forms `LogRecord` and passes it to all appenders.', () }); } + // @ts-expect-error ECS custom meta logger.fatal('message-3', { fatal: true }); for (const appenderMock of appenderMocks) { expect(appenderMock.append).toHaveBeenCalledTimes(3); diff --git a/src/core/server/logging/logger.ts b/src/core/server/logging/logger.ts index 4ba334cec2fb9..e025c28a88f0e 100644 --- a/src/core/server/logging/logger.ts +++ b/src/core/server/logging/logger.ts @@ -21,28 +21,28 @@ export class BaseLogger implements Logger { private readonly factory: LoggerFactory ) {} - public trace(message: string, meta?: LogMeta): void { - this.log(this.createLogRecord(LogLevel.Trace, message, meta)); + public trace(message: string, meta?: Meta): void { + this.log(this.createLogRecord(LogLevel.Trace, message, meta)); } - public debug(message: string, meta?: LogMeta): void { - this.log(this.createLogRecord(LogLevel.Debug, message, meta)); + public debug(message: string, meta?: Meta): void { + this.log(this.createLogRecord(LogLevel.Debug, message, meta)); } - public info(message: string, meta?: LogMeta): void { - this.log(this.createLogRecord(LogLevel.Info, message, meta)); + public info(message: string, meta?: Meta): void { + this.log(this.createLogRecord(LogLevel.Info, message, meta)); } - public warn(errorOrMessage: string | Error, meta?: LogMeta): void { - this.log(this.createLogRecord(LogLevel.Warn, errorOrMessage, meta)); + public warn(errorOrMessage: string | Error, meta?: Meta): void { + this.log(this.createLogRecord(LogLevel.Warn, errorOrMessage, meta)); } - public error(errorOrMessage: string | Error, meta?: LogMeta): void { - this.log(this.createLogRecord(LogLevel.Error, errorOrMessage, meta)); + public error(errorOrMessage: string | Error, meta?: Meta): void { + this.log(this.createLogRecord(LogLevel.Error, errorOrMessage, meta)); } - public fatal(errorOrMessage: string | Error, meta?: LogMeta): void { - this.log(this.createLogRecord(LogLevel.Fatal, errorOrMessage, meta)); + public fatal(errorOrMessage: string | Error, meta?: Meta): void { + this.log(this.createLogRecord(LogLevel.Fatal, errorOrMessage, meta)); } public log(record: LogRecord) { @@ -59,10 +59,10 @@ export class BaseLogger implements Logger { return this.factory.get(...[this.context, ...childContextPaths]); } - private createLogRecord( + private createLogRecord( level: LogLevel, errorOrMessage: string | Error, - meta?: LogMeta + meta?: Meta ): LogRecord { if (isError(errorOrMessage)) { return { diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index b67be384732cb..9c4313bc0c49d 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -49,6 +49,7 @@ test('uses default memory buffer logger until config is provided', () => { // We shouldn't create new buffer appender for another context name. const anotherLogger = system.get('test', 'context2'); + // @ts-expect-error ECS custom meta anotherLogger.fatal('fatal message', { some: 'value' }); expect(bufferAppendSpy).toHaveBeenCalledTimes(2); @@ -62,6 +63,7 @@ test('flushes memory buffer logger and switches to real logger once config is pr const logger = system.get('test', 'context'); logger.trace('buffered trace message'); + // @ts-expect-error ECS custom meta logger.info('buffered info message', { some: 'value' }); logger.fatal('buffered fatal message'); @@ -159,6 +161,7 @@ test('attaches appenders to appenders that declare refs', async () => { ); const testLogger = system.get('tests'); + // @ts-expect-error ECS custom meta testLogger.warn('This message goes to a test context.', { a: 'hi', b: 'remove me' }); expect(mockConsoleLog).toHaveBeenCalledTimes(1); @@ -233,6 +236,7 @@ test('asLoggerFactory() only allows to create new loggers.', async () => { ); logger.trace('buffered trace message'); + // @ts-expect-error ECS custom meta logger.info('buffered info message', { some: 'value' }); logger.fatal('buffered fatal message'); diff --git a/src/core/server/metrics/logging/get_ops_metrics_log.test.ts b/src/core/server/metrics/logging/get_ops_metrics_log.test.ts index 014d3ae258823..e535b9babf92b 100644 --- a/src/core/server/metrics/logging/get_ops_metrics_log.test.ts +++ b/src/core/server/metrics/logging/get_ops_metrics_log.test.ts @@ -66,7 +66,7 @@ describe('getEcsOpsMetricsLog', () => { it('correctly formats process uptime', () => { const logMeta = getEcsOpsMetricsLog(createMockOpsMetrics(testMetrics)); - expect(logMeta.process!.uptime).toEqual(1); + expect(logMeta.meta.process!.uptime).toEqual(1); }); it('excludes values from the message if unavailable', () => { @@ -80,44 +80,40 @@ describe('getEcsOpsMetricsLog', () => { expect(logMeta.message).toMatchInlineSnapshot(`""`); }); - it('specifies correct ECS version', () => { - const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics()); - expect(logMeta.ecs.version).toBe('1.7.0'); - }); - it('provides an ECS-compatible response', () => { const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics()); expect(logMeta).toMatchInlineSnapshot(` Object { - "ecs": Object { - "version": "1.7.0", - }, - "event": Object { - "category": Array [ - "process", - "host", - ], - "kind": "metric", - "type": "info", - }, - "host": Object { - "os": Object { - "load": Object { - "15m": 1, - "1m": 1, - "5m": 1, + "message": "memory: 1.0B load: [1.00,1.00,1.00] delay: 1.000", + "meta": Object { + "event": Object { + "category": Array [ + "process", + "host", + ], + "kind": "metric", + "type": Array [ + "info", + ], + }, + "host": Object { + "os": Object { + "load": Object { + "15m": 1, + "1m": 1, + "5m": 1, + }, }, }, - }, - "message": "memory: 1.0B load: [1.00,1.00,1.00] delay: 1.000", - "process": Object { - "eventLoopDelay": 1, - "memory": Object { - "heap": Object { - "usedInBytes": 1, + "process": Object { + "eventLoopDelay": 1, + "memory": Object { + "heap": Object { + "usedInBytes": 1, + }, }, + "uptime": 0, }, - "uptime": 0, }, } `); @@ -125,8 +121,8 @@ describe('getEcsOpsMetricsLog', () => { it('logs ECS fields in the log meta', () => { const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics()); - expect(logMeta.event!.kind).toBe('metric'); - expect(logMeta.event!.category).toEqual(expect.arrayContaining(['process', 'host'])); - expect(logMeta.event!.type).toBe('info'); + expect(logMeta.meta.event!.kind).toBe('metric'); + expect(logMeta.meta.event!.category).toEqual(expect.arrayContaining(['process', 'host'])); + expect(logMeta.meta.event!.type).toEqual(expect.arrayContaining(['info'])); }); }); diff --git a/src/core/server/metrics/logging/get_ops_metrics_log.ts b/src/core/server/metrics/logging/get_ops_metrics_log.ts index 02c3ad312c7dd..7e13f35889ec7 100644 --- a/src/core/server/metrics/logging/get_ops_metrics_log.ts +++ b/src/core/server/metrics/logging/get_ops_metrics_log.ts @@ -7,16 +7,15 @@ */ import numeral from '@elastic/numeral'; -import { EcsEvent, EcsEventKind, EcsEventCategory, EcsEventType } from '../../logging'; +import { LogMeta } from '@kbn/logging'; import { OpsMetrics } from '..'; -const ECS_VERSION = '1.7.0'; /** * Converts ops metrics into ECS-compliant `LogMeta` for logging * * @internal */ -export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent { +export function getEcsOpsMetricsLog(metrics: OpsMetrics) { const { process, os } = metrics; const processMemoryUsedInBytes = process?.memory?.heap?.used_in_bytes; const processMemoryUsedInBytesMsg = processMemoryUsedInBytes @@ -51,13 +50,11 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent { })}] ` : ''; - return { - ecs: { version: ECS_VERSION }, - message: `${processMemoryUsedInBytesMsg}${uptimeValMsg}${loadValsMsg}${eventLoopDelayValMsg}`, + const meta: LogMeta = { event: { - kind: EcsEventKind.METRIC, - category: [EcsEventCategory.PROCESS, EcsEventCategory.HOST], - type: EcsEventType.INFO, + kind: 'metric', + category: ['process', 'host'], + type: ['info'], }, process: { uptime: uptimeVal, @@ -71,8 +68,14 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent { }, host: { os: { + // @ts-expect-error custom fields not yet part of ECS load: loadEntries, }, }, }; + + return { + message: `${processMemoryUsedInBytesMsg}${uptimeValMsg}${loadValsMsg}${eventLoopDelayValMsg}`, + meta, + }; } diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts index 4fbca5addda11..d7de41fd7ccf7 100644 --- a/src/core/server/metrics/metrics_service.test.ts +++ b/src/core/server/metrics/metrics_service.test.ts @@ -182,16 +182,15 @@ describe('MetricsService', () => { Array [ "", Object { - "ecs": Object { - "version": "1.7.0", - }, "event": Object { "category": Array [ "process", "host", ], "kind": "metric", - "type": "info", + "type": Array [ + "info", + ], }, "host": Object { "os": Object { diff --git a/src/core/server/metrics/metrics_service.ts b/src/core/server/metrics/metrics_service.ts index 382848e0a80c3..78e4dd98f93d6 100644 --- a/src/core/server/metrics/metrics_service.ts +++ b/src/core/server/metrics/metrics_service.ts @@ -73,7 +73,7 @@ export class MetricsService private async refreshMetrics() { const metrics = await this.metricsCollector!.collect(); - const { message, ...meta } = getEcsOpsMetricsLog(metrics); + const { message, meta } = getEcsOpsMetricsLog(metrics); this.opsMetricsLogger.debug(message!, meta); this.metricsCollector!.reset(); this.metrics$.next(metrics); diff --git a/src/core/server/plugins/plugins_service.mock.ts b/src/core/server/plugins/plugins_service.mock.ts index 1d0ed7cb09299..f4f2263a1bdb0 100644 --- a/src/core/server/plugins/plugins_service.mock.ts +++ b/src/core/server/plugins/plugins_service.mock.ts @@ -19,6 +19,7 @@ const createStartContractMock = () => ({ contracts: new Map() }); const createServiceMock = (): PluginsServiceMock => ({ discover: jest.fn(), + getExposedPluginConfigsToUsage: jest.fn(), setup: jest.fn().mockResolvedValue(createSetupContractMock()), start: jest.fn().mockResolvedValue(createStartContractMock()), stop: jest.fn(), diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 6bf7a1fadb4d3..5c50df07dc697 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -78,7 +78,7 @@ const createPlugin = ( manifest: { id, version, - configPath: `${configPath}${disabled ? '-disabled' : ''}`, + configPath: disabled ? configPath.concat('-disabled') : configPath, kibanaVersion, requiredPlugins, requiredBundles, @@ -374,7 +374,6 @@ describe('PluginsService', () => { expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); - expect(mockDiscover).toHaveBeenCalledTimes(1); expect(mockDiscover).toHaveBeenCalledWith( { @@ -472,6 +471,88 @@ describe('PluginsService', () => { expect(pluginPaths).toEqual(['/plugin-A-path', '/plugin-B-path']); }); + + it('ppopulates pluginConfigUsageDescriptors with plugins exposeToUsage property', async () => { + const pluginA = createPlugin('plugin-with-expose-usage', { + path: 'plugin-with-expose-usage', + configPath: 'pathA', + }); + + jest.doMock( + join('plugin-with-expose-usage', 'server'), + () => ({ + config: { + exposeToUsage: { + test: true, + nested: { + prop: true, + }, + }, + schema: schema.maybe(schema.any()), + }, + }), + { + virtual: true, + } + ); + + const pluginB = createPlugin('plugin-with-array-configPath', { + path: 'plugin-with-array-configPath', + configPath: ['plugin', 'pathB'], + }); + + jest.doMock( + join('plugin-with-array-configPath', 'server'), + () => ({ + config: { + exposeToUsage: { + test: true, + }, + schema: schema.maybe(schema.any()), + }, + }), + { + virtual: true, + } + ); + + jest.doMock( + join('plugin-without-expose', 'server'), + () => ({ + config: { + schema: schema.maybe(schema.any()), + }, + }), + { + virtual: true, + } + ); + + const pluginC = createPlugin('plugin-without-expose', { + path: 'plugin-without-expose', + configPath: 'pathC', + }); + + mockDiscover.mockReturnValue({ + error$: from([]), + plugin$: from([pluginA, pluginB, pluginC]), + }); + + await pluginsService.discover({ environment: environmentSetup }); + + // eslint-disable-next-line dot-notation + expect(pluginsService['pluginConfigUsageDescriptors']).toMatchInlineSnapshot(` + Map { + "pathA" => Object { + "nested.prop": true, + "test": true, + }, + "plugin.pathB" => Object { + "test": true, + }, + } + `); + }); }); describe('#generateUiPluginsConfigs()', () => { @@ -624,6 +705,20 @@ describe('PluginsService', () => { }); }); + describe('#getExposedPluginConfigsToUsage', () => { + it('returns pluginConfigUsageDescriptors', () => { + // eslint-disable-next-line dot-notation + pluginsService['pluginConfigUsageDescriptors'].set('test', { enabled: true }); + expect(pluginsService.getExposedPluginConfigsToUsage()).toMatchInlineSnapshot(` + Map { + "test" => Object { + "enabled": true, + }, + } + `); + }); + }); + describe('#stop()', () => { it('`stop` stops plugins system', async () => { await pluginsService.stop(); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 09be40ecaf2a2..547fe00fdb1cf 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -9,7 +9,7 @@ import Path from 'path'; import { Observable } from 'rxjs'; import { filter, first, map, mergeMap, tap, toArray } from 'rxjs/operators'; -import { pick } from '@kbn/std'; +import { pick, getFlattenedObject } from '@kbn/std'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; @@ -75,6 +75,7 @@ export class PluginsService implements CoreService; private readonly pluginConfigDescriptors = new Map(); private readonly uiPluginInternalInfo = new Map(); + private readonly pluginConfigUsageDescriptors = new Map>(); constructor(private readonly coreContext: CoreContext) { this.log = coreContext.logger.get('plugins-service'); @@ -109,6 +110,10 @@ export class PluginsService implements CoreService = T | undefined; + /** * Dedicated type for plugin configuration schema. * @@ -70,8 +72,39 @@ export interface PluginConfigDescriptor { * {@link PluginConfigSchema} */ schema: PluginConfigSchema; + /** + * Expose non-default configs to usage collection to be sent via telemetry. + * set a config to `true` to report the actual changed config value. + * set a config to `false` to report the changed config value as [redacted]. + * + * All changed configs except booleans and numbers will be reported + * as [redacted] unless otherwise specified. + * + * {@link MakeUsageFromSchema} + */ + exposeToUsage?: MakeUsageFromSchema; } +/** + * List of configuration values that will be exposed to usage collection. + * If parent node or actual config path is set to `true` then the actual value + * of these configs will be reoprted. + * If parent node or actual config path is set to `false` then the config + * will be reported as [redacted]. + * + * @public + */ +export type MakeUsageFromSchema = { + [Key in keyof T]?: T[Key] extends Maybe + ? // arrays of objects are always redacted + false + : T[Key] extends Maybe + ? boolean + : T[Key] extends Maybe + ? MakeUsageFromSchema | boolean + : boolean; +}; + /** * Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays * that use it as a key or value more obvious. diff --git a/src/core/server/saved_objects/migrations/core/migration_logger.ts b/src/core/server/saved_objects/migrations/core/migration_logger.ts index e8cb6352195de..6c935b915ce68 100644 --- a/src/core/server/saved_objects/migrations/core/migration_logger.ts +++ b/src/core/server/saved_objects/migrations/core/migration_logger.ts @@ -24,7 +24,7 @@ export interface SavedObjectsMigrationLogger { */ warning: (msg: string) => void; warn: (msg: string) => void; - error: (msg: string, meta: LogMeta) => void; + error: (msg: string, meta: Meta) => void; } export class MigrationLogger implements SavedObjectsMigrationLogger { diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts index 7f3ee03f1437d..0e51c886f7f30 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts @@ -26,8 +26,7 @@ async function removeLogFile() { await asyncUnlink(logFilePath).catch(() => void 0); } -// FAILING: https://github.com/elastic/kibana/pull/96788 -describe.skip('migration from 7.7.2-xpack with 100k objects', () => { +describe('migration from 7.7.2-xpack with 100k objects', () => { let esServer: kbnTestServer.TestElasticsearchUtils; let root: Root; let coreStart: InternalCoreStart; diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index fa2e65f16bb2d..a6617fc2fb7f4 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -211,86 +211,90 @@ describe('migrationsStateActionMachine', () => { Array [ "[.my-so-index] INIT -> LEGACY_DELETE", Object { - "batchSize": 1000, - "controlState": "LEGACY_DELETE", - "currentAlias": ".my-so-index", - "indexPrefix": ".my-so-index", - "kibanaVersion": "7.11.0", - "legacyIndex": ".my-so-index", - "logs": Array [ - Object { - "level": "info", - "message": "Log from LEGACY_DELETE control state", - }, - ], - "outdatedDocuments": Array [ - "1234", - ], - "outdatedDocumentsQuery": Object { - "bool": Object { - "should": Array [], - }, - }, - "preMigrationScript": Object { - "_tag": "None", - }, - "reason": "the fatal reason", - "retryAttempts": 5, - "retryCount": 0, - "retryDelay": 0, - "targetIndexMappings": Object { - "properties": Object {}, - }, - "tempIndex": ".my-so-index_7.11.0_reindex_temp", - "tempIndexMappings": Object { - "dynamic": false, - "properties": Object { - "migrationVersion": Object { - "dynamic": "true", - "type": "object", + "kibana": Object { + "migrationState": Object { + "batchSize": 1000, + "controlState": "LEGACY_DELETE", + "currentAlias": ".my-so-index", + "indexPrefix": ".my-so-index", + "kibanaVersion": "7.11.0", + "legacyIndex": ".my-so-index", + "logs": Array [ + Object { + "level": "info", + "message": "Log from LEGACY_DELETE control state", + }, + ], + "outdatedDocuments": Array [ + "1234", + ], + "outdatedDocumentsQuery": Object { + "bool": Object { + "should": Array [], + }, }, - "type": Object { - "type": "keyword", + "preMigrationScript": Object { + "_tag": "None", }, - }, - }, - "unusedTypesQuery": Object { - "_tag": "Some", - "value": Object { - "bool": Object { - "must_not": Array [ - Object { - "term": Object { - "type": "fleet-agent-events", - }, + "reason": "the fatal reason", + "retryAttempts": 5, + "retryCount": 0, + "retryDelay": 0, + "targetIndexMappings": Object { + "properties": Object {}, + }, + "tempIndex": ".my-so-index_7.11.0_reindex_temp", + "tempIndexMappings": Object { + "dynamic": false, + "properties": Object { + "migrationVersion": Object { + "dynamic": "true", + "type": "object", }, - Object { - "term": Object { - "type": "tsvb-validation-telemetry", - }, + "type": Object { + "type": "keyword", }, - Object { - "bool": Object { - "must": Array [ - Object { - "match": Object { - "type": "search-session", - }, + }, + }, + "unusedTypesQuery": Object { + "_tag": "Some", + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", }, - Object { - "match": Object { - "search-session.persisted": false, - }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", }, - ], - }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], + }, + }, + ], }, - ], + }, }, + "versionAlias": ".my-so-index_7.11.0", + "versionIndex": ".my-so-index_7.11.0_001", }, }, - "versionAlias": ".my-so-index_7.11.0", - "versionIndex": ".my-so-index_7.11.0_001", }, ], Array [ @@ -303,90 +307,94 @@ describe('migrationsStateActionMachine', () => { Array [ "[.my-so-index] LEGACY_DELETE -> FATAL", Object { - "batchSize": 1000, - "controlState": "FATAL", - "currentAlias": ".my-so-index", - "indexPrefix": ".my-so-index", - "kibanaVersion": "7.11.0", - "legacyIndex": ".my-so-index", - "logs": Array [ - Object { - "level": "info", - "message": "Log from LEGACY_DELETE control state", - }, - Object { - "level": "info", - "message": "Log from FATAL control state", - }, - ], - "outdatedDocuments": Array [ - "1234", - ], - "outdatedDocumentsQuery": Object { - "bool": Object { - "should": Array [], - }, - }, - "preMigrationScript": Object { - "_tag": "None", - }, - "reason": "the fatal reason", - "retryAttempts": 5, - "retryCount": 0, - "retryDelay": 0, - "targetIndexMappings": Object { - "properties": Object {}, - }, - "tempIndex": ".my-so-index_7.11.0_reindex_temp", - "tempIndexMappings": Object { - "dynamic": false, - "properties": Object { - "migrationVersion": Object { - "dynamic": "true", - "type": "object", + "kibana": Object { + "migrationState": Object { + "batchSize": 1000, + "controlState": "FATAL", + "currentAlias": ".my-so-index", + "indexPrefix": ".my-so-index", + "kibanaVersion": "7.11.0", + "legacyIndex": ".my-so-index", + "logs": Array [ + Object { + "level": "info", + "message": "Log from LEGACY_DELETE control state", + }, + Object { + "level": "info", + "message": "Log from FATAL control state", + }, + ], + "outdatedDocuments": Array [ + "1234", + ], + "outdatedDocumentsQuery": Object { + "bool": Object { + "should": Array [], + }, }, - "type": Object { - "type": "keyword", + "preMigrationScript": Object { + "_tag": "None", }, - }, - }, - "unusedTypesQuery": Object { - "_tag": "Some", - "value": Object { - "bool": Object { - "must_not": Array [ - Object { - "term": Object { - "type": "fleet-agent-events", - }, + "reason": "the fatal reason", + "retryAttempts": 5, + "retryCount": 0, + "retryDelay": 0, + "targetIndexMappings": Object { + "properties": Object {}, + }, + "tempIndex": ".my-so-index_7.11.0_reindex_temp", + "tempIndexMappings": Object { + "dynamic": false, + "properties": Object { + "migrationVersion": Object { + "dynamic": "true", + "type": "object", }, - Object { - "term": Object { - "type": "tsvb-validation-telemetry", - }, + "type": Object { + "type": "keyword", }, - Object { - "bool": Object { - "must": Array [ - Object { - "match": Object { - "type": "search-session", - }, + }, + }, + "unusedTypesQuery": Object { + "_tag": "Some", + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", + }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", }, - Object { - "match": Object { - "search-session.persisted": false, - }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], }, - ], - }, + }, + ], }, - ], + }, }, + "versionAlias": ".my-so-index_7.11.0", + "versionIndex": ".my-so-index_7.11.0_001", }, }, - "versionAlias": ".my-so-index_7.11.0", - "versionIndex": ".my-so-index_7.11.0_001", }, ], ] @@ -490,84 +498,88 @@ describe('migrationsStateActionMachine', () => { Array [ "[.my-so-index] INIT -> LEGACY_REINDEX", Object { - "batchSize": 1000, - "controlState": "LEGACY_REINDEX", - "currentAlias": ".my-so-index", - "indexPrefix": ".my-so-index", - "kibanaVersion": "7.11.0", - "legacyIndex": ".my-so-index", - "logs": Array [ - Object { - "level": "info", - "message": "Log from LEGACY_REINDEX control state", - }, - ], - "outdatedDocuments": Array [], - "outdatedDocumentsQuery": Object { - "bool": Object { - "should": Array [], - }, - }, - "preMigrationScript": Object { - "_tag": "None", - }, - "reason": "the fatal reason", - "retryAttempts": 5, - "retryCount": 0, - "retryDelay": 0, - "targetIndexMappings": Object { - "properties": Object {}, - }, - "tempIndex": ".my-so-index_7.11.0_reindex_temp", - "tempIndexMappings": Object { - "dynamic": false, - "properties": Object { - "migrationVersion": Object { - "dynamic": "true", - "type": "object", + "kibana": Object { + "migrationState": Object { + "batchSize": 1000, + "controlState": "LEGACY_REINDEX", + "currentAlias": ".my-so-index", + "indexPrefix": ".my-so-index", + "kibanaVersion": "7.11.0", + "legacyIndex": ".my-so-index", + "logs": Array [ + Object { + "level": "info", + "message": "Log from LEGACY_REINDEX control state", + }, + ], + "outdatedDocuments": Array [], + "outdatedDocumentsQuery": Object { + "bool": Object { + "should": Array [], + }, }, - "type": Object { - "type": "keyword", + "preMigrationScript": Object { + "_tag": "None", }, - }, - }, - "unusedTypesQuery": Object { - "_tag": "Some", - "value": Object { - "bool": Object { - "must_not": Array [ - Object { - "term": Object { - "type": "fleet-agent-events", - }, + "reason": "the fatal reason", + "retryAttempts": 5, + "retryCount": 0, + "retryDelay": 0, + "targetIndexMappings": Object { + "properties": Object {}, + }, + "tempIndex": ".my-so-index_7.11.0_reindex_temp", + "tempIndexMappings": Object { + "dynamic": false, + "properties": Object { + "migrationVersion": Object { + "dynamic": "true", + "type": "object", }, - Object { - "term": Object { - "type": "tsvb-validation-telemetry", - }, + "type": Object { + "type": "keyword", }, - Object { - "bool": Object { - "must": Array [ - Object { - "match": Object { - "type": "search-session", - }, + }, + }, + "unusedTypesQuery": Object { + "_tag": "Some", + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", }, - Object { - "match": Object { - "search-session.persisted": false, - }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", }, - ], - }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], + }, + }, + ], }, - ], + }, }, + "versionAlias": ".my-so-index_7.11.0", + "versionIndex": ".my-so-index_7.11.0_001", }, }, - "versionAlias": ".my-so-index_7.11.0", - "versionIndex": ".my-so-index_7.11.0_001", }, ], Array [ @@ -577,88 +589,92 @@ describe('migrationsStateActionMachine', () => { Array [ "[.my-so-index] LEGACY_REINDEX -> LEGACY_DELETE", Object { - "batchSize": 1000, - "controlState": "LEGACY_DELETE", - "currentAlias": ".my-so-index", - "indexPrefix": ".my-so-index", - "kibanaVersion": "7.11.0", - "legacyIndex": ".my-so-index", - "logs": Array [ - Object { - "level": "info", - "message": "Log from LEGACY_REINDEX control state", - }, - Object { - "level": "info", - "message": "Log from LEGACY_DELETE control state", - }, - ], - "outdatedDocuments": Array [], - "outdatedDocumentsQuery": Object { - "bool": Object { - "should": Array [], - }, - }, - "preMigrationScript": Object { - "_tag": "None", - }, - "reason": "the fatal reason", - "retryAttempts": 5, - "retryCount": 0, - "retryDelay": 0, - "targetIndexMappings": Object { - "properties": Object {}, - }, - "tempIndex": ".my-so-index_7.11.0_reindex_temp", - "tempIndexMappings": Object { - "dynamic": false, - "properties": Object { - "migrationVersion": Object { - "dynamic": "true", - "type": "object", + "kibana": Object { + "migrationState": Object { + "batchSize": 1000, + "controlState": "LEGACY_DELETE", + "currentAlias": ".my-so-index", + "indexPrefix": ".my-so-index", + "kibanaVersion": "7.11.0", + "legacyIndex": ".my-so-index", + "logs": Array [ + Object { + "level": "info", + "message": "Log from LEGACY_REINDEX control state", + }, + Object { + "level": "info", + "message": "Log from LEGACY_DELETE control state", + }, + ], + "outdatedDocuments": Array [], + "outdatedDocumentsQuery": Object { + "bool": Object { + "should": Array [], + }, }, - "type": Object { - "type": "keyword", + "preMigrationScript": Object { + "_tag": "None", }, - }, - }, - "unusedTypesQuery": Object { - "_tag": "Some", - "value": Object { - "bool": Object { - "must_not": Array [ - Object { - "term": Object { - "type": "fleet-agent-events", - }, + "reason": "the fatal reason", + "retryAttempts": 5, + "retryCount": 0, + "retryDelay": 0, + "targetIndexMappings": Object { + "properties": Object {}, + }, + "tempIndex": ".my-so-index_7.11.0_reindex_temp", + "tempIndexMappings": Object { + "dynamic": false, + "properties": Object { + "migrationVersion": Object { + "dynamic": "true", + "type": "object", }, - Object { - "term": Object { - "type": "tsvb-validation-telemetry", - }, + "type": Object { + "type": "keyword", }, - Object { - "bool": Object { - "must": Array [ - Object { - "match": Object { - "type": "search-session", - }, + }, + }, + "unusedTypesQuery": Object { + "_tag": "Some", + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", + }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", }, - Object { - "match": Object { - "search-session.persisted": false, - }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], }, - ], - }, + }, + ], }, - ], + }, }, + "versionAlias": ".my-so-index_7.11.0", + "versionIndex": ".my-so-index_7.11.0_001", }, }, - "versionAlias": ".my-so-index_7.11.0", - "versionIndex": ".my-so-index_7.11.0_001", }, ], ] diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts index e35e21421ac1f..20177dda63b3b 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts @@ -13,6 +13,12 @@ import { CorruptSavedObjectError } from '../migrations/core/migrate_raw_docs'; import { Model, Next, stateActionMachine } from './state_action_machine'; import { State } from './types'; +interface StateLogMeta extends LogMeta { + kibana: { + migrationState: State; + }; +} + type ExecutionLog = Array< | { type: 'transition'; @@ -35,9 +41,15 @@ const logStateTransition = ( tookMs: number ) => { if (newState.logs.length > oldState.logs.length) { - newState.logs - .slice(oldState.logs.length) - .forEach((log) => logger[log.level](logMessagePrefix + log.message)); + newState.logs.slice(oldState.logs.length).forEach((log) => { + const getLogger = (level: keyof Logger) => { + if (level === 'error') { + return logger[level] as Logger['error']; + } + return logger[level] as Logger['info']; + }; + getLogger(log.level)(logMessagePrefix + log.message); + }); } logger.info( @@ -58,7 +70,14 @@ const dumpExecutionLog = (logger: Logger, logMessagePrefix: string, executionLog logger.error(logMessagePrefix + 'migration failed, dumping execution log:'); executionLog.forEach((log) => { if (log.type === 'transition') { - logger.info(logMessagePrefix + `${log.prevControlState} -> ${log.controlState}`, log.state); + logger.info( + logMessagePrefix + `${log.prevControlState} -> ${log.controlState}`, + { + kibana: { + migrationState: log.state, + }, + } + ); } if (log.type === 'response') { logger.info(logMessagePrefix + `${log.controlState} RESPONSE`, log.res as LogMeta); diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 6ba23747cf374..d21039db30e5f 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -44,6 +44,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen has_reference_operator: searchOperatorSchema, fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), filter: schema.maybe(schema.string()), + aggs: schema.maybe(schema.string()), namespaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), @@ -59,6 +60,20 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsFind({ request: req }).catch(() => {}); + // manually validation to avoid using JSON.parse twice + let aggs; + if (query.aggs) { + try { + aggs = JSON.parse(query.aggs); + } catch (e) { + return res.badRequest({ + body: { + message: 'invalid aggs value', + }, + }); + } + } + const result = await context.core.savedObjects.client.find({ perPage: query.per_page, page: query.page, @@ -72,6 +87,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen hasReferenceOperator: query.has_reference_operator, fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, + aggs, namespaces, }); diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts new file mode 100644 index 0000000000000..1508cab69a048 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts @@ -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 { schema as s, ObjectType } from '@kbn/config-schema'; + +/** + * Schemas for the Bucket aggregations. + * + * Currently supported: + * - filter + * - histogram + * - terms + * + * Not implemented: + * - adjacency_matrix + * - auto_date_histogram + * - children + * - composite + * - date_histogram + * - date_range + * - diversified_sampler + * - filters + * - geo_distance + * - geohash_grid + * - geotile_grid + * - global + * - ip_range + * - missing + * - multi_terms + * - nested + * - parent + * - range + * - rare_terms + * - reverse_nested + * - sampler + * - significant_terms + * - significant_text + * - variable_width_histogram + */ +export const bucketAggsSchemas: Record = { + filter: s.object({ + term: s.recordOf(s.string(), s.oneOf([s.string(), s.boolean(), s.number()])), + }), + histogram: s.object({ + field: s.maybe(s.string()), + interval: s.maybe(s.number()), + min_doc_count: s.maybe(s.number()), + extended_bounds: s.maybe( + s.object({ + min: s.number(), + max: s.number(), + }) + ), + hard_bounds: s.maybe( + s.object({ + min: s.number(), + max: s.number(), + }) + ), + missing: s.maybe(s.number()), + keyed: s.maybe(s.boolean()), + order: s.maybe( + s.object({ + _count: s.string(), + _key: s.string(), + }) + ), + }), + terms: s.object({ + field: s.maybe(s.string()), + collect_mode: s.maybe(s.string()), + exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + execution_hint: s.maybe(s.string()), + missing: s.maybe(s.number()), + min_doc_count: s.maybe(s.number()), + size: s.maybe(s.number()), + show_term_doc_count_error: s.maybe(s.boolean()), + order: s.maybe(s.oneOf([s.literal('asc'), s.literal('desc')])), + }), +}; diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts new file mode 100644 index 0000000000000..7967fad0185fb --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { bucketAggsSchemas } from './bucket_aggs'; +import { metricsAggsSchemas } from './metrics_aggs'; + +export const aggregationSchemas = { + ...metricsAggsSchemas, + ...bucketAggsSchemas, +}; diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts new file mode 100644 index 0000000000000..c05ae67cd2164 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { schema as s, ObjectType } from '@kbn/config-schema'; + +/** + * Schemas for the metrics Aggregations + * + * Currently supported: + * - avg + * - cardinality + * - min + * - max + * - sum + * - top_hits + * - weighted_avg + * + * Not implemented: + * - boxplot + * - extended_stats + * - geo_bounds + * - geo_centroid + * - geo_line + * - matrix_stats + * - median_absolute_deviation + * - percentile_ranks + * - percentiles + * - rate + * - scripted_metric + * - stats + * - string_stats + * - t_test + * - value_count + */ +export const metricsAggsSchemas: Record = { + avg: s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + }), + cardinality: s.object({ + field: s.maybe(s.string()), + precision_threshold: s.maybe(s.number()), + rehash: s.maybe(s.boolean()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + }), + min: s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + format: s.maybe(s.string()), + }), + max: s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + format: s.maybe(s.string()), + }), + sum: s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])), + }), + top_hits: s.object({ + explain: s.maybe(s.boolean()), + docvalue_fields: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + stored_fields: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + from: s.maybe(s.number()), + size: s.maybe(s.number()), + sort: s.maybe(s.oneOf([s.literal('asc'), s.literal('desc')])), + seq_no_primary_term: s.maybe(s.boolean()), + version: s.maybe(s.boolean()), + track_scores: s.maybe(s.boolean()), + highlight: s.maybe(s.any()), + _source: s.maybe(s.oneOf([s.boolean(), s.string(), s.arrayOf(s.string())])), + }), + weighted_avg: s.object({ + format: s.maybe(s.string()), + value_type: s.maybe(s.string()), + value: s.maybe( + s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.number()), + }) + ), + weight: s.maybe( + s.object({ + field: s.maybe(s.string()), + missing: s.maybe(s.number()), + }) + ), + }), +}; diff --git a/src/core/server/saved_objects/service/lib/aggregations/index.ts b/src/core/server/saved_objects/service/lib/aggregations/index.ts new file mode 100644 index 0000000000000..f71d3e8daea9d --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { validateAndConvertAggregations } from './validation'; diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts new file mode 100644 index 0000000000000..8a7c1c3719eb0 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts @@ -0,0 +1,431 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { validateAndConvertAggregations } from './validation'; + +type AggsMap = Record; + +const mockMappings = { + properties: { + updated_at: { + type: 'date', + }, + foo: { + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + bytes: { + type: 'number', + }, + }, + }, + bean: { + properties: { + canned: { + fields: { + text: { + type: 'text', + }, + }, + type: 'keyword', + }, + }, + }, + alert: { + properties: { + actions: { + type: 'nested', + properties: { + group: { + type: 'keyword', + }, + actionRef: { + type: 'keyword', + }, + actionTypeId: { + type: 'keyword', + }, + params: { + enabled: false, + type: 'object', + }, + }, + }, + params: { + type: 'flattened', + }, + }, + }, + }, +}; + +describe('validateAndConvertAggregations', () => { + it('validates a simple aggregations', () => { + expect( + validateAndConvertAggregations( + ['foo'], + { aggName: { max: { field: 'foo.attributes.bytes' } } }, + mockMappings + ) + ).toEqual({ + aggName: { + max: { + field: 'foo.bytes', + }, + }, + }); + }); + + it('validates a nested field in simple aggregations', () => { + expect( + validateAndConvertAggregations( + ['alert'], + { aggName: { cardinality: { field: 'alert.attributes.actions.group' } } }, + mockMappings + ) + ).toEqual({ + aggName: { + cardinality: { + field: 'alert.actions.group', + }, + }, + }); + }); + + it('validates a nested aggregations', () => { + expect( + validateAndConvertAggregations( + ['alert'], + { + aggName: { + cardinality: { + field: 'alert.attributes.actions.group', + }, + aggs: { + aggName: { + max: { field: 'alert.attributes.actions.group' }, + }, + }, + }, + }, + mockMappings + ) + ).toEqual({ + aggName: { + cardinality: { + field: 'alert.actions.group', + }, + aggs: { + aggName: { + max: { + field: 'alert.actions.group', + }, + }, + }, + }, + }); + }); + + it('validates a deeply nested aggregations', () => { + expect( + validateAndConvertAggregations( + ['alert'], + { + first: { + cardinality: { + field: 'alert.attributes.actions.group', + }, + aggs: { + second: { + max: { field: 'alert.attributes.actions.group' }, + aggs: { + third: { + min: { + field: 'alert.attributes.actions.actionTypeId', + }, + }, + }, + }, + }, + }, + }, + mockMappings + ) + ).toEqual({ + first: { + cardinality: { + field: 'alert.actions.group', + }, + aggs: { + second: { + max: { field: 'alert.actions.group' }, + aggs: { + third: { + min: { + field: 'alert.actions.actionTypeId', + }, + }, + }, + }, + }, + }, + }); + }); + + it('rewrites type attributes when valid', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.attributes.actions.group', + missing: 10, + }, + }, + }; + expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({ + average: { + avg: { + field: 'alert.actions.group', + missing: 10, + }, + }, + }); + }); + + it('rewrites root attributes when valid', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.updated_at', + missing: 10, + }, + }, + }; + expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({ + average: { + avg: { + field: 'updated_at', + missing: 10, + }, + }, + }); + }); + + it('throws an error when the `field` name is not using attributes path', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.actions.group', + missing: 10, + }, + }, + }; + expect(() => + validateAndConvertAggregations(['alert'], aggregations, mockMappings) + ).toThrowErrorMatchingInlineSnapshot( + `"[average.avg.field] Invalid attribute path: alert.actions.group"` + ); + }); + + it('throws an error when the `field` name is referencing an invalid field', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.attributes.actions.non_existing', + missing: 10, + }, + }, + }; + expect(() => + validateAndConvertAggregations(['alert'], aggregations, mockMappings) + ).toThrowErrorMatchingInlineSnapshot( + `"[average.avg.field] Invalid attribute path: alert.attributes.actions.non_existing"` + ); + }); + + it('throws an error when the attribute path is referencing an invalid root field', () => { + const aggregations: AggsMap = { + average: { + avg: { + field: 'alert.bad_root', + missing: 10, + }, + }, + }; + expect(() => + validateAndConvertAggregations(['alert'], aggregations, mockMappings) + ).toThrowErrorMatchingInlineSnapshot( + `"[average.avg.field] Invalid attribute path: alert.bad_root"` + ); + }); + + it('rewrites the `field` name even when nested', () => { + const aggregations: AggsMap = { + average: { + weighted_avg: { + value: { + field: 'alert.attributes.actions.group', + missing: 10, + }, + weight: { + field: 'alert.attributes.actions.actionRef', + }, + }, + }, + }; + expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({ + average: { + weighted_avg: { + value: { + field: 'alert.actions.group', + missing: 10, + }, + weight: { + field: 'alert.actions.actionRef', + }, + }, + }, + }); + }); + + it('rewrites the entries of a filter term record', () => { + const aggregations: AggsMap = { + myFilter: { + filter: { + term: { + 'foo.attributes.description': 'hello', + 'foo.attributes.bytes': 10, + }, + }, + }, + }; + expect(validateAndConvertAggregations(['foo'], aggregations, mockMappings)).toEqual({ + myFilter: { + filter: { + term: { 'foo.description': 'hello', 'foo.bytes': 10 }, + }, + }, + }); + }); + + it('throws an error when referencing non-allowed types', () => { + const aggregations: AggsMap = { + myFilter: { + max: { + field: 'foo.attributes.bytes', + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['alert'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[myFilter.max.field] Invalid attribute path: foo.attributes.bytes"` + ); + }); + + it('throws an error when an attributes is not respecting its schema definition', () => { + const aggregations: AggsMap = { + someAgg: { + terms: { + missing: 'expecting a number', + }, + }, + }; + + expect(() => + validateAndConvertAggregations(['alert'], aggregations, mockMappings) + ).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.terms.missing]: expected value of type [number] but got [string]"` + ); + }); + + it('throws an error when trying to validate an unknown aggregation type', () => { + const aggregations: AggsMap = { + someAgg: { + auto_date_histogram: { + field: 'foo.attributes.bytes', + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['foo'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.auto_date_histogram] auto_date_histogram aggregation is not valid (or not registered yet)"` + ); + }); + + it('throws an error when a child aggregation is unknown', () => { + const aggregations: AggsMap = { + someAgg: { + max: { + field: 'foo.attributes.bytes', + }, + aggs: { + unknownAgg: { + cumulative_cardinality: { + format: 'format', + }, + }, + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['foo'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.aggs.unknownAgg.cumulative_cardinality] cumulative_cardinality aggregation is not valid (or not registered yet)"` + ); + }); + + it('throws an error when using a script attribute', () => { + const aggregations: AggsMap = { + someAgg: { + max: { + field: 'foo.attributes.bytes', + script: 'This is a bad script', + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['foo'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.max.script]: definition for this key is missing"` + ); + }); + + it('throws an error when using a script attribute in a nested aggregation', () => { + const aggregations: AggsMap = { + someAgg: { + min: { + field: 'foo.attributes.bytes', + }, + aggs: { + nested: { + max: { + field: 'foo.attributes.bytes', + script: 'This is a bad script', + }, + }, + }, + }, + }; + + expect(() => { + validateAndConvertAggregations(['foo'], aggregations, mockMappings); + }).toThrowErrorMatchingInlineSnapshot( + `"[someAgg.aggs.nested.max.script]: definition for this key is missing"` + ); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.ts new file mode 100644 index 0000000000000..a2fd392183132 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.ts @@ -0,0 +1,229 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { ObjectType } from '@kbn/config-schema'; +import { isPlainObject } from 'lodash'; + +import { IndexMapping } from '../../../mappings'; +import { + isObjectTypeAttribute, + rewriteObjectTypeAttribute, + isRootLevelAttribute, + rewriteRootLevelAttribute, +} from './validation_utils'; +import { aggregationSchemas } from './aggs_types'; + +const aggregationKeys = ['aggs', 'aggregations']; + +interface ValidationContext { + allowedTypes: string[]; + indexMapping: IndexMapping; + currentPath: string[]; +} + +/** + * Validate an aggregation structure against the declared mappings and + * aggregation schemas, and rewrite the attribute fields using the KQL-like syntax + * - `{type}.attributes.{attribute}` to `{type}.{attribute}` + * - `{type}.{rootField}` to `{rootField}` + * + * throws on the first validation error if any is encountered. + */ +export const validateAndConvertAggregations = ( + allowedTypes: string[], + aggs: Record, + indexMapping: IndexMapping +): Record => { + return validateAggregations(aggs, { + allowedTypes, + indexMapping, + currentPath: [], + }); +}; + +/** + * Validate a record of aggregation containers, + * Which can either be the root level aggregations (`SearchRequest.body.aggs`) + * Or a nested record of aggregation (`SearchRequest.body.aggs.myAggregation.aggs`) + */ +const validateAggregations = ( + aggregations: Record, + context: ValidationContext +) => { + return Object.entries(aggregations).reduce((memo, [aggrName, aggrContainer]) => { + memo[aggrName] = validateAggregation(aggrContainer, childContext(context, aggrName)); + return memo; + }, {} as Record); +}; + +/** + * Validate an aggregation container, e.g an entry of `SearchRequest.body.aggs`, or + * from a nested aggregation record, including its potential nested aggregations. + */ +const validateAggregation = ( + aggregation: estypes.AggregationContainer, + context: ValidationContext +) => { + const container = validateAggregationContainer(aggregation, context); + + if (aggregation.aggregations) { + container.aggregations = validateAggregations( + aggregation.aggregations, + childContext(context, 'aggregations') + ); + } + if (aggregation.aggs) { + container.aggs = validateAggregations(aggregation.aggs, childContext(context, 'aggs')); + } + + return container; +}; + +/** + * Validates root-level aggregation of given aggregation container + * (ignoring its nested aggregations) + */ +const validateAggregationContainer = ( + container: estypes.AggregationContainer, + context: ValidationContext +) => { + return Object.entries(container).reduce((memo, [aggName, aggregation]) => { + if (aggregationKeys.includes(aggName)) { + return memo; + } + return { + ...memo, + [aggName]: validateAggregationType(aggName, aggregation, childContext(context, aggName)), + }; + }, {} as estypes.AggregationContainer); +}; + +const validateAggregationType = ( + aggregationType: string, + aggregation: Record, + context: ValidationContext +) => { + const aggregationSchema = aggregationSchemas[aggregationType]; + if (!aggregationSchema) { + throw new Error( + `[${context.currentPath.join( + '.' + )}] ${aggregationType} aggregation is not valid (or not registered yet)` + ); + } + + validateAggregationStructure(aggregationSchema, aggregation, context); + return validateAndRewriteFieldAttributes(aggregation, context); +}; + +/** + * Validate an aggregation structure against its declared schema. + */ +const validateAggregationStructure = ( + schema: ObjectType, + aggObject: unknown, + context: ValidationContext +) => { + return schema.validate(aggObject, {}, context.currentPath.join('.')); +}; + +/** + * List of fields that have an attribute path as value + * + * @example + * ```ts + * avg: { + * field: 'alert.attributes.actions.group', + * }, + * ``` + */ +const attributeFields = ['field']; +/** + * List of fields that have a Record as value + * + * @example + * ```ts + * filter: { + * term: { + * 'alert.attributes.actions.group': 'value' + * }, + * }, + * ``` + */ +const attributeMaps = ['term']; + +const validateAndRewriteFieldAttributes = ( + aggregation: Record, + context: ValidationContext +) => { + return recursiveRewrite(aggregation, context, []); +}; + +const recursiveRewrite = ( + currentLevel: Record, + context: ValidationContext, + parents: string[] +): Record => { + return Object.entries(currentLevel).reduce((memo, [key, value]) => { + const rewriteKey = isAttributeKey(parents); + const rewriteValue = isAttributeValue(key, value); + + const nestedContext = childContext(context, key); + const newKey = rewriteKey ? validateAndRewriteAttributePath(key, nestedContext) : key; + const newValue = rewriteValue + ? validateAndRewriteAttributePath(value, nestedContext) + : isPlainObject(value) + ? recursiveRewrite(value, nestedContext, [...parents, key]) + : value; + + return { + ...memo, + [newKey]: newValue, + }; + }, {}); +}; + +const childContext = (context: ValidationContext, path: string): ValidationContext => { + return { + ...context, + currentPath: [...context.currentPath, path], + }; +}; + +const lastParent = (parents: string[]) => { + if (parents.length) { + return parents[parents.length - 1]; + } + return undefined; +}; + +const isAttributeKey = (parents: string[]) => { + const last = lastParent(parents); + if (last) { + return attributeMaps.includes(last); + } + return false; +}; + +const isAttributeValue = (fieldName: string, fieldValue: unknown): boolean => { + return attributeFields.includes(fieldName) && typeof fieldValue === 'string'; +}; + +const validateAndRewriteAttributePath = ( + attributePath: string, + { allowedTypes, indexMapping, currentPath }: ValidationContext +) => { + if (isRootLevelAttribute(attributePath, indexMapping, allowedTypes)) { + return rewriteRootLevelAttribute(attributePath); + } + if (isObjectTypeAttribute(attributePath, indexMapping, allowedTypes)) { + return rewriteObjectTypeAttribute(attributePath); + } + throw new Error(`[${currentPath.join('.')}] Invalid attribute path: ${attributePath}`); +}; diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts new file mode 100644 index 0000000000000..25c3aea474ece --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { IndexMapping } from '../../../mappings'; +import { + isRootLevelAttribute, + rewriteRootLevelAttribute, + isObjectTypeAttribute, + rewriteObjectTypeAttribute, +} from './validation_utils'; + +const mockMappings: IndexMapping = { + properties: { + updated_at: { + type: 'date', + }, + foo: { + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + bytes: { + type: 'number', + }, + }, + }, + bean: { + properties: { + canned: { + fields: { + text: { + type: 'text', + }, + }, + type: 'keyword', + }, + }, + }, + alert: { + properties: { + actions: { + type: 'nested', + properties: { + group: { + type: 'keyword', + }, + actionRef: { + type: 'keyword', + }, + actionTypeId: { + type: 'keyword', + }, + params: { + enabled: false, + type: 'object', + }, + }, + }, + params: { + type: 'flattened', + }, + }, + }, + }, +}; + +describe('isRootLevelAttribute', () => { + it('returns true when referring to a path to a valid root level field', () => { + expect(isRootLevelAttribute('foo.updated_at', mockMappings, ['foo'])).toBe(true); + }); + it('returns false when referring to a direct path to a valid root level field', () => { + expect(isRootLevelAttribute('updated_at', mockMappings, ['foo'])).toBe(false); + }); + it('returns false when referring to a path to a unknown root level field', () => { + expect(isRootLevelAttribute('foo.not_present', mockMappings, ['foo'])).toBe(false); + }); + it('returns false when referring to a path to an existing nested field', () => { + expect(isRootLevelAttribute('foo.properties.title', mockMappings, ['foo'])).toBe(false); + }); + it('returns false when referring to a path to a valid root level field of an unknown type', () => { + expect(isRootLevelAttribute('bar.updated_at', mockMappings, ['foo'])).toBe(false); + }); + it('returns false when referring to a path to a valid root level type field', () => { + expect(isRootLevelAttribute('foo.foo', mockMappings, ['foo'])).toBe(false); + }); +}); + +describe('rewriteRootLevelAttribute', () => { + it('rewrites the attribute path to strip the type', () => { + expect(rewriteRootLevelAttribute('foo.references')).toEqual('references'); + }); + it('does not handle real root level path', () => { + expect(rewriteRootLevelAttribute('references')).not.toEqual('references'); + }); +}); + +describe('isObjectTypeAttribute', () => { + it('return true if attribute path is valid', () => { + expect(isObjectTypeAttribute('foo.attributes.description', mockMappings, ['foo'])).toEqual( + true + ); + }); + + it('return true for nested attributes', () => { + expect(isObjectTypeAttribute('bean.attributes.canned.text', mockMappings, ['bean'])).toEqual( + true + ); + }); + + it('return false if attribute path points to an invalid type', () => { + expect(isObjectTypeAttribute('foo.attributes.description', mockMappings, ['bean'])).toEqual( + false + ); + }); + + it('returns false if attribute path refers to a type', () => { + expect(isObjectTypeAttribute('bean', mockMappings, ['bean'])).toEqual(false); + }); + + it('Return error if key does not match SO attribute structure', () => { + expect(isObjectTypeAttribute('bean.canned.text', mockMappings, ['bean'])).toEqual(false); + }); + + it('Return false if key matches nested type attribute parent', () => { + expect(isObjectTypeAttribute('alert.actions', mockMappings, ['alert'])).toEqual(false); + }); + + it('returns false if path refers to a non-existent attribute', () => { + expect(isObjectTypeAttribute('bean.attributes.red', mockMappings, ['bean'])).toEqual(false); + }); +}); + +describe('rewriteObjectTypeAttribute', () => { + it('rewrites the attribute path to strip the type', () => { + expect(rewriteObjectTypeAttribute('foo.attributes.prop')).toEqual('foo.prop'); + }); + it('returns invalid input unchanged', () => { + expect(rewriteObjectTypeAttribute('foo.references')).toEqual('foo.references'); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts new file mode 100644 index 0000000000000..f817497e3759e --- /dev/null +++ b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts @@ -0,0 +1,80 @@ +/* + * 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 { IndexMapping } from '../../../mappings'; +import { fieldDefined, hasFilterKeyError } from '../filter_utils'; + +/** + * Returns true if the given attribute path is a valid root level SO attribute path + * + * @example + * ```ts + * isRootLevelAttribute('myType.updated_at', indexMapping, ['myType']}) + * // => true + * ``` + */ +export const isRootLevelAttribute = ( + attributePath: string, + indexMapping: IndexMapping, + allowedTypes: string[] +): boolean => { + const splits = attributePath.split('.'); + if (splits.length !== 2) { + return false; + } + + const [type, fieldName] = splits; + if (allowedTypes.includes(fieldName)) { + return false; + } + return allowedTypes.includes(type) && fieldDefined(indexMapping, fieldName); +}; + +/** + * Rewrites a root level attribute path to strip the type + * + * @example + * ```ts + * rewriteRootLevelAttribute('myType.updated_at') + * // => 'updated_at' + * ``` + */ +export const rewriteRootLevelAttribute = (attributePath: string) => { + return attributePath.split('.')[1]; +}; + +/** + * Returns true if the given attribute path is a valid object type level SO attribute path + * + * @example + * ```ts + * isObjectTypeAttribute('myType.attributes.someField', indexMapping, ['myType']}) + * // => true + * ``` + */ +export const isObjectTypeAttribute = ( + attributePath: string, + indexMapping: IndexMapping, + allowedTypes: string[] +): boolean => { + const error = hasFilterKeyError(attributePath, allowedTypes, indexMapping); + return error == null; +}; + +/** + * Rewrites a object type attribute path to strip the type + * + * @example + * ```ts + * rewriteObjectTypeAttribute('myType.attributes.foo') + * // => 'myType.foo' + * ``` + */ +export const rewriteObjectTypeAttribute = (attributePath: string) => { + return attributePath.replace('.attributes', ''); +}; diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts index b50326627cf09..2ef5219ccfff1 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { cloneDeep } from 'lodash'; // @ts-expect-error no ts import { esKuery } from '../../es_query'; @@ -18,7 +19,7 @@ import { const mockMappings = { properties: { - updatedAt: { + updated_at: { type: 'date', }, foo: { @@ -105,6 +106,22 @@ describe('Filter Utils', () => { ) ).toEqual(esKuery.fromKueryExpression('foo.title: "best"')); }); + + test('does not mutate the input KueryNode', () => { + const input = esKuery.nodeTypes.function.buildNode( + 'is', + `foo.attributes.title`, + 'best', + true + ); + + const inputCopy = cloneDeep(input); + + validateConvertFilterToKueryNode(['foo'], input, mockMappings); + + expect(input).toEqual(inputCopy); + }); + test('Validate a simple KQL expression filter', () => { expect( validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockMappings) @@ -123,12 +140,12 @@ describe('Filter Utils', () => { expect( validateConvertFilterToKueryNode( ['foo'], - 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', mockMappings ) ).toEqual( esKuery.fromKueryExpression( - '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' + '(type: foo and updated_at: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' ) ); }); @@ -137,12 +154,12 @@ describe('Filter Utils', () => { expect( validateConvertFilterToKueryNode( ['foo', 'bar'], - 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', mockMappings ) ).toEqual( esKuery.fromKueryExpression( - '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' + '(type: foo and updated_at: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' ) ); }); @@ -151,12 +168,12 @@ describe('Filter Utils', () => { expect( validateConvertFilterToKueryNode( ['foo', 'bar'], - '(bar.updatedAt: 5678654567 OR foo.updatedAt: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)', + '(bar.updated_at: 5678654567 OR foo.updated_at: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)', mockMappings ) ).toEqual( esKuery.fromKueryExpression( - '((type: bar and updatedAt: 5678654567) or (type: foo and updatedAt: 5678654567)) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or bar.description :*)' + '((type: bar and updated_at: 5678654567) or (type: foo and updated_at: 5678654567)) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or bar.description :*)' ) ); }); @@ -181,11 +198,11 @@ describe('Filter Utils', () => { expect(() => { validateConvertFilterToKueryNode( ['foo', 'bar'], - 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + 'updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', mockMappings ); }).toThrowErrorMatchingInlineSnapshot( - `"This key 'updatedAt' need to be wrapped by a saved object type like foo,bar: Bad Request"` + `"This key 'updated_at' need to be wrapped by a saved object type like foo,bar: Bad Request"` ); }); @@ -200,7 +217,7 @@ describe('Filter Utils', () => { test('Validate filter query through KueryNode - happy path', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -211,7 +228,7 @@ describe('Filter Utils', () => { astPath: 'arguments.0', error: null, isSavedObjectAttr: true, - key: 'foo.updatedAt', + key: 'foo.updated_at', type: 'foo', }, { @@ -275,7 +292,7 @@ describe('Filter Utils', () => { test('Return Error if key is not wrapper by a saved object type', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + 'updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -284,9 +301,9 @@ describe('Filter Utils', () => { expect(validationObject).toEqual([ { astPath: 'arguments.0', - error: "This key 'updatedAt' need to be wrapped by a saved object type like foo", + error: "This key 'updated_at' need to be wrapped by a saved object type like foo", isSavedObjectAttr: true, - key: 'updatedAt', + key: 'updated_at', type: null, }, { @@ -330,7 +347,7 @@ describe('Filter Utils', () => { test('Return Error if key of a saved object type is not wrapped with attributes', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)' + 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -341,7 +358,7 @@ describe('Filter Utils', () => { astPath: 'arguments.0', error: null, isSavedObjectAttr: true, - key: 'foo.updatedAt', + key: 'foo.updated_at', type: 'foo', }, { @@ -387,7 +404,7 @@ describe('Filter Utils', () => { test('Return Error if filter is not using an allowed type', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'bar.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + 'bar.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -398,7 +415,7 @@ describe('Filter Utils', () => { astPath: 'arguments.0', error: 'This type bar is not allowed', isSavedObjectAttr: true, - key: 'bar.updatedAt', + key: 'bar.updated_at', type: 'bar', }, { @@ -442,7 +459,7 @@ describe('Filter Utils', () => { test('Return Error if filter is using an non-existing key in the index patterns of the saved object type', () => { const validationObject = validateFilterKueryNode({ astFilter: esKuery.fromKueryExpression( - 'foo.updatedAt33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + 'foo.updated_at33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' ), types: ['foo'], indexMapping: mockMappings, @@ -451,9 +468,9 @@ describe('Filter Utils', () => { expect(validationObject).toEqual([ { astPath: 'arguments.0', - error: "This key 'foo.updatedAt33' does NOT exist in foo saved object index patterns", + error: "This key 'foo.updated_at33' does NOT exist in foo saved object index patterns", isSavedObjectAttr: false, - key: 'foo.updatedAt33', + key: 'foo.updated_at33', type: 'foo', }, { @@ -519,6 +536,33 @@ describe('Filter Utils', () => { }, ]); }); + + test('Validate multiple items nested filter query through KueryNode', () => { + const validationObject = validateFilterKueryNode({ + astFilter: esKuery.fromKueryExpression( + 'alert.attributes.actions:{ actionTypeId: ".server-log" AND actionRef: "foo" }' + ), + types: ['alert'], + indexMapping: mockMappings, + }); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'alert.attributes.actions.actionTypeId', + type: 'alert', + }, + { + astPath: 'arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'alert.attributes.actions.actionRef', + type: 'alert', + }, + ]); + }); }); describe('#hasFilterKeyError', () => { diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index 688b7ad96e8ed..a41a25a27b70d 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -7,11 +7,12 @@ */ import { set } from '@elastic/safer-lodash-set'; -import { get } from 'lodash'; +import { get, cloneDeep } from 'lodash'; import { SavedObjectsErrorHelpers } from './errors'; import { IndexMapping } from '../../mappings'; // @ts-expect-error no ts import { esKuery } from '../../es_query'; + type KueryNode = any; const astFunctionType = ['is', 'range', 'nested']; @@ -23,7 +24,7 @@ export const validateConvertFilterToKueryNode = ( ): KueryNode | undefined => { if (filter && indexMapping) { const filterKueryNode = - typeof filter === 'string' ? esKuery.fromKueryExpression(filter) : filter; + typeof filter === 'string' ? esKuery.fromKueryExpression(filter) : cloneDeep(filter); const validationFilterKuery = validateFilterKueryNode({ astFilter: filterKueryNode, @@ -109,7 +110,15 @@ export const validateFilterKueryNode = ({ return astFilter.arguments.reduce((kueryNode: string[], ast: KueryNode, index: number) => { if (hasNestedKey && ast.type === 'literal' && ast.value != null) { localNestedKeys = ast.value; + } else if (ast.type === 'literal' && ast.value && typeof ast.value === 'string') { + const key = ast.value.replace('.attributes', ''); + const mappingKey = 'properties.' + key.split('.').join('.properties.'); + const field = get(indexMapping, mappingKey); + if (field != null && field.type === 'nested') { + localNestedKeys = ast.value; + } } + if (ast.arguments) { const myPath = `${path}.${index}`; return [ @@ -121,7 +130,7 @@ export const validateFilterKueryNode = ({ storeValue: ast.type === 'function' && astFunctionType.includes(ast.function), path: `${myPath}.arguments`, hasNestedKey: ast.type === 'function' && ast.function === 'nested', - nestedKeys: localNestedKeys, + nestedKeys: localNestedKeys || nestedKeys, }), ]; } @@ -226,7 +235,7 @@ export const fieldDefined = (indexMappings: IndexMapping, key: string): boolean return true; } - // If the path is for a flattned type field, we'll assume the mappings are defined. + // If the path is for a flattened type field, we'll assume the mappings are defined. const keys = key.split('.'); for (let i = 0; i < keys.length; i++) { const path = `properties.${keys.slice(0, i + 1).join('.properties.')}`; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 7c719ac56a835..c0e2cdc333363 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -66,6 +66,7 @@ import { import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; +import { validateAndConvertAggregations } from './aggregations'; import { ALL_NAMESPACES_STRING, FIND_DEFAULT_PAGE, @@ -748,7 +749,9 @@ export class SavedObjectsRepository { * @property {string} [options.preference] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ - async find(options: SavedObjectsFindOptions): Promise> { + async find( + options: SavedObjectsFindOptions + ): Promise> { const { search, defaultSearchOperator = 'OR', @@ -768,6 +771,7 @@ export class SavedObjectsRepository { typeToNamespacesMap, filter, preference, + aggs, } = options; if (!type && !typeToNamespacesMap) { @@ -799,7 +803,7 @@ export class SavedObjectsRepository { : Array.from(typeToNamespacesMap!.keys()); const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); if (allowedTypes.length === 0) { - return SavedObjectsUtils.createEmptyFindResponse(options); + return SavedObjectsUtils.createEmptyFindResponse(options); } if (searchFields && !Array.isArray(searchFields)) { @@ -811,16 +815,24 @@ export class SavedObjectsRepository { } let kueryNode; - - try { - if (filter) { + if (filter) { + try { kueryNode = validateConvertFilterToKueryNode(allowedTypes, filter, this._mappings); + } catch (e) { + if (e.name === 'KQLSyntaxError') { + throw SavedObjectsErrorHelpers.createBadRequestError(`KQLSyntaxError: ${e.message}`); + } else { + throw e; + } } - } catch (e) { - if (e.name === 'KQLSyntaxError') { - throw SavedObjectsErrorHelpers.createBadRequestError('KQLSyntaxError: ' + e.message); - } else { - throw e; + } + + let aggsObject; + if (aggs) { + try { + aggsObject = validateAndConvertAggregations(allowedTypes, aggs, this._mappings); + } catch (e) { + throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid aggregation: ${e.message}`); } } @@ -838,6 +850,7 @@ export class SavedObjectsRepository { seq_no_primary_term: true, from: perPage * (page - 1), _source: includedFields(type, fields), + ...(aggsObject ? { aggs: aggsObject } : {}), ...getSearchDsl(this._mappings, this._registry, { search, defaultSearchOperator, @@ -872,6 +885,7 @@ export class SavedObjectsRepository { } return { + ...(body.aggregations ? { aggregations: (body.aggregations as unknown) as A } : {}), page, per_page: perPage, total: body.hits.total, @@ -885,7 +899,7 @@ export class SavedObjectsRepository { }) ), pit_id: body.pit_id, - } as SavedObjectsFindResponse; + } as SavedObjectsFindResponse; } /** diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index ebad13e5edc25..494ac6ce9fad5 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -51,10 +51,10 @@ export class SavedObjectsUtils { /** * Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. */ - public static createEmptyFindResponse = ({ + public static createEmptyFindResponse = ({ page = FIND_DEFAULT_PAGE, perPage = FIND_DEFAULT_PER_PAGE, - }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({ + }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({ page, per_page: perPage, total: 0, 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 9a0ccb88d3555..12451ace02836 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -173,7 +173,8 @@ export interface SavedObjectsFindResult extends SavedObject { * * @public */ -export interface SavedObjectsFindResponse { +export interface SavedObjectsFindResponse { + aggregations?: A; saved_objects: Array>; total: number; per_page: number; @@ -463,7 +464,9 @@ export class SavedObjectsClient { * * @param options */ - async find(options: SavedObjectsFindOptions): Promise> { + async find( + options: SavedObjectsFindOptions + ): Promise> { return await this._repository.find(options); } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index ecda120e025d8..d3bfdcc6923dc 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -116,6 +116,28 @@ export interface SavedObjectsFindOptions { */ defaultSearchOperator?: 'AND' | 'OR'; filter?: string | KueryNode; + /** + * A record of aggregations to perform. + * The API currently only supports a limited set of metrics and bucket aggregation types. + * Additional aggregation types can be contributed to Core. + * + * @example + * Aggregating on SO attribute field + * ```ts + * const aggs = { latest_version: { max: { field: 'dashboard.attributes.version' } } }; + * return client.find({ type: 'dashboard', aggs }) + * ``` + * + * @example + * Aggregating on SO root field + * ```ts + * const aggs = { latest_update: { max: { field: 'dashboard.updated_at' } } }; + * return client.find({ type: 'dashboard', aggs }) + * ``` + * + * @alpha + */ + aggs?: Record; namespaces?: string[]; /** * This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 05af684053f39..b4c6ee323cbac 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -49,6 +49,11 @@ import { DeleteTemplateParams } from 'elasticsearch'; import { DetailedPeerCertificate } from 'tls'; import { Duration } from 'moment'; import { Duration as Duration_2 } from 'moment-timezone'; +import { Ecs } from '@kbn/logging'; +import { EcsEventCategory } from '@kbn/logging'; +import { EcsEventKind } from '@kbn/logging'; +import { EcsEventOutcome } from '@kbn/logging'; +import { EcsEventType } from '@kbn/logging'; import { EnvironmentMode } from '@kbn/config'; import { estypes } from '@elastic/elasticsearch'; import { ExistsParams } from 'elasticsearch'; @@ -381,6 +386,9 @@ export { ConfigPath } export { ConfigService } +// @internal +export type ConfigUsageData = Record; + // @public export interface ContextSetup { createContextContainer(): IContextContainer; @@ -442,6 +450,13 @@ export interface CoreConfigUsageData { supportedProtocols: string[]; clientAuthentication: 'none' | 'optional' | 'required'; }; + securityResponseHeaders: { + strictTransportSecurity: string; + xContentTypeOptions: string; + referrerPolicy: string; + permissionsPolicyConfigured: boolean; + disableEmbedding: boolean; + }; }; // (undocumented) logging: { @@ -551,6 +566,8 @@ export interface CoreUsageData extends CoreUsageStats { // @internal export interface CoreUsageDataStart { + // (undocumented) + getConfigsUsageData(): Promise; getCoreUsageData(): Promise; } @@ -757,6 +774,8 @@ export class CspConfig implements ICspConfig { // (undocumented) static readonly DEFAULT: CspConfig; // (undocumented) + readonly disableEmbedding: boolean; + // (undocumented) readonly header: string; // (undocumented) readonly rules: string[]; @@ -877,6 +896,16 @@ export interface DiscoveredPlugin { readonly requiredPlugins: readonly PluginName[]; } +export { Ecs } + +export { EcsEventCategory } + +export { EcsEventKind } + +export { EcsEventOutcome } + +export { EcsEventType } + // @public export type ElasticsearchClient = Omit & { transport: { @@ -1113,6 +1142,7 @@ export type IContextProvider = { + [Key in keyof T]?: T[Key] extends Maybe ? false : T[Key] extends Maybe ? boolean : T[Key] extends Maybe ? MakeUsageFromSchema | boolean : boolean; +}; + // @public export interface MetricsServiceSetup { readonly collectionInterval: number; @@ -1838,6 +1875,7 @@ export interface PluginConfigDescriptor { exposeToBrowser?: { [P in keyof T]?: boolean; }; + exposeToUsage?: MakeUsageFromSchema; schema: PluginConfigSchema; } @@ -2244,7 +2282,7 @@ export class SavedObjectsClient { static errors: typeof SavedObjectsErrorHelpers; // (undocumented) errors: typeof SavedObjectsErrorHelpers; - find(options: SavedObjectsFindOptions): Promise>; + find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; @@ -2501,6 +2539,8 @@ export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjec // @public (undocumented) export interface SavedObjectsFindOptions { + // @alpha + aggs?: Record; defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts @@ -2539,7 +2579,9 @@ export interface SavedObjectsFindOptionsReference { } // @public -export interface SavedObjectsFindResponse { +export interface SavedObjectsFindResponse { + // (undocumented) + aggregations?: A; // (undocumented) page: number; // (undocumented) @@ -2765,7 +2807,7 @@ export interface SavedObjectsMigrationLogger { // (undocumented) debug: (msg: string) => void; // (undocumented) - error: (msg: string, meta: LogMeta) => void; + error: (msg: string, meta: Meta) => void; // (undocumented) info: (msg: string) => void; // (undocumented) @@ -2849,7 +2891,7 @@ export class SavedObjectsRepository { deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) - find(options: SavedObjectsFindOptions): Promise>; + find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; @@ -2970,7 +3012,7 @@ export interface SavedObjectsUpdateResponse extends Omit({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; + static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; static generateId(): string; static isRandomId(id: string | undefined): boolean; static namespaceIdToString: (namespace?: string | undefined) => string; @@ -3220,9 +3262,9 @@ export const validBodyOutput: readonly ["data", "stream"]; // // src/core/server/elasticsearch/client/types.ts:94:7 - (ae-forgotten-export) The symbol "Explanation" needs to be exported by the entry point index.d.ts // src/core/server/http/router/response.ts:297:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:293:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:293:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:296:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:401:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "create" +// src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:329:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:434:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "create" ``` diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 45d11f9013fed..fcfca3a5e0e2f 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -247,6 +247,7 @@ export class Server { const coreUsageDataStart = this.coreUsageData.start({ elasticsearch: elasticsearchStart, savedObjects: savedObjectsStart, + exposedConfigsToUsage: this.plugins.getExposedPluginConfigsToUsage(), }); this.coreStart = { @@ -271,10 +272,10 @@ export class Server { this.log.debug('stopping server'); await this.legacy.stop(); + await this.http.stop(); // HTTP server has to stop before savedObjects and ES clients are closed to be able to gracefully attempt to resolve any pending requests await this.plugins.stop(); await this.savedObjects.stop(); await this.elasticsearch.stop(); - await this.http.stop(); await this.uiSettings.stop(); await this.rendering.stop(); await this.metrics.stop(); diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 09cf5b92b2b8a..7724e7a5e44b4 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -12,7 +12,7 @@ import { isDeepStrictEqual } from 'util'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; -import { Logger } from '../logging'; +import { Logger, LogMeta } from '../logging'; import { InternalElasticsearchServiceSetup } from '../elasticsearch'; import { InternalHttpServiceSetup } from '../http'; import { InternalSavedObjectsServiceSetup } from '../saved_objects'; @@ -26,6 +26,10 @@ import { ServiceStatus, CoreStatus, InternalStatusServiceSetup } from './types'; import { getSummaryStatus } from './get_summary_status'; import { PluginsStatusService } from './plugins_status'; +interface StatusLogMeta extends LogMeta { + kibana: { status: ServiceStatus }; +} + interface SetupDeps { elasticsearch: Pick; environment: InternalEnvironmentServiceSetup; @@ -70,7 +74,11 @@ export class StatusService implements CoreService { ...Object.entries(coreStatus), ...Object.entries(pluginsStatus), ]); - this.logger.debug(`Recalculated overall status`, { status: summary }); + this.logger.debug(`Recalculated overall status`, { + kibana: { + status: summary, + }, + }); return summary; }), distinctUntilChanged(isDeepStrictEqual), diff --git a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts index b169c715b9b95..669849dcd8d9b 100644 --- a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts +++ b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts @@ -131,8 +131,12 @@ describe('uiSettings/createOrUpgradeSavedConfig', function () { Array [ "Upgrade config from 4.0.0 to 4.0.1", Object { - "newVersion": "4.0.1", - "prevVersion": "4.0.0", + "kibana": Object { + "config": Object { + "newVersion": "4.0.1", + "prevVersion": "4.0.0", + }, + }, }, ], ] diff --git a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts index a32556d1aef6f..d015f506df6e3 100644 --- a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts +++ b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts @@ -10,10 +10,16 @@ import { defaults } from 'lodash'; import { SavedObjectsClientContract } from '../../saved_objects/types'; import { SavedObjectsErrorHelpers } from '../../saved_objects/'; -import { Logger } from '../../logging'; +import { Logger, LogMeta } from '../../logging'; import { getUpgradeableConfig } from './get_upgradeable_config'; +interface ConfigLogMeta extends LogMeta { + kibana: { + config: { prevVersion: string; newVersion: string }; + }; +} + interface Options { savedObjectsClient: SavedObjectsClientContract; version: string; @@ -60,9 +66,13 @@ export async function createOrUpgradeSavedConfig( } if (upgradeableConfig) { - log.debug(`Upgrade config from ${upgradeableConfig.id} to ${version}`, { - prevVersion: upgradeableConfig.id, - newVersion: version, + log.debug(`Upgrade config from ${upgradeableConfig.id} to ${version}`, { + kibana: { + config: { + prevVersion: upgradeableConfig.id, + newVersion: version, + }, + }, }); } } diff --git a/src/core/server/ui_settings/settings/notifications.test.ts b/src/core/server/ui_settings/settings/notifications.test.ts index c06371b3d731e..01e2905b0cc2c 100644 --- a/src/core/server/ui_settings/settings/notifications.test.ts +++ b/src/core/server/ui_settings/settings/notifications.test.ts @@ -36,15 +36,15 @@ describe('notifications settings', () => { expect(() => validate(42)).not.toThrow(); expect(() => validate('Infinity')).not.toThrow(); expect(() => validate(-12)).toThrowErrorMatchingInlineSnapshot(` -"types that failed validation: -- [0]: Value must be equal to or greater than [0]. -- [1]: expected value to equal [Infinity]" -`); + "types that failed validation: + - [0]: Value must be equal to or greater than [0]. + - [1]: expected value to equal [Infinity]" + `); expect(() => validate('foo')).toThrowErrorMatchingInlineSnapshot(` -"types that failed validation: -- [0]: expected value of type [number] but got [string] -- [1]: expected value to equal [Infinity]" -`); + "types that failed validation: + - [0]: expected value of type [number] but got [string] + - [1]: expected value to equal [Infinity]" + `); }); }); @@ -55,15 +55,15 @@ describe('notifications settings', () => { expect(() => validate(42)).not.toThrow(); expect(() => validate('Infinity')).not.toThrow(); expect(() => validate(-12)).toThrowErrorMatchingInlineSnapshot(` -"types that failed validation: -- [0]: Value must be equal to or greater than [0]. -- [1]: expected value to equal [Infinity]" -`); + "types that failed validation: + - [0]: Value must be equal to or greater than [0]. + - [1]: expected value to equal [Infinity]" + `); expect(() => validate('foo')).toThrowErrorMatchingInlineSnapshot(` -"types that failed validation: -- [0]: expected value of type [number] but got [string] -- [1]: expected value to equal [Infinity]" -`); + "types that failed validation: + - [0]: expected value of type [number] but got [string] + - [1]: expected value to equal [Infinity]" + `); }); }); @@ -74,15 +74,15 @@ describe('notifications settings', () => { expect(() => validate(42)).not.toThrow(); expect(() => validate('Infinity')).not.toThrow(); expect(() => validate(-12)).toThrowErrorMatchingInlineSnapshot(` -"types that failed validation: -- [0]: Value must be equal to or greater than [0]. -- [1]: expected value to equal [Infinity]" -`); + "types that failed validation: + - [0]: Value must be equal to or greater than [0]. + - [1]: expected value to equal [Infinity]" + `); expect(() => validate('foo')).toThrowErrorMatchingInlineSnapshot(` -"types that failed validation: -- [0]: expected value of type [number] but got [string] -- [1]: expected value to equal [Infinity]" -`); + "types that failed validation: + - [0]: expected value of type [number] but got [string] + - [1]: expected value to equal [Infinity]" + `); }); }); @@ -93,15 +93,15 @@ describe('notifications settings', () => { expect(() => validate(42)).not.toThrow(); expect(() => validate('Infinity')).not.toThrow(); expect(() => validate(-12)).toThrowErrorMatchingInlineSnapshot(` -"types that failed validation: -- [0]: Value must be equal to or greater than [0]. -- [1]: expected value to equal [Infinity]" -`); + "types that failed validation: + - [0]: Value must be equal to or greater than [0]. + - [1]: expected value to equal [Infinity]" + `); expect(() => validate('foo')).toThrowErrorMatchingInlineSnapshot(` -"types that failed validation: -- [0]: expected value of type [number] but got [string] -- [1]: expected value to equal [Infinity]" -`); + "types that failed validation: + - [0]: expected value of type [number] but got [string] + - [1]: expected value to equal [Infinity]" + `); }); }); }); diff --git a/src/core/server/ui_settings/settings/notifications.ts b/src/core/server/ui_settings/settings/notifications.ts index 22bdf17681808..746f7851a748f 100644 --- a/src/core/server/ui_settings/settings/notifications.ts +++ b/src/core/server/ui_settings/settings/notifications.ts @@ -45,15 +45,11 @@ export const getNotificationsSettings = (): Record => value: 3000000, description: i18n.translate('core.ui_settings.params.notifications.bannerLifetimeText', { defaultMessage: - 'The time in milliseconds which a banner notification will be displayed on-screen for. ' + - 'Setting to {infinityValue} will disable the countdown.', - values: { - infinityValue: 'Infinity', - }, + 'The time in milliseconds which a banner notification will be displayed on-screen for. ', }), type: 'number', category: ['notifications'], - schema: schema.oneOf([schema.number({ min: 0 }), schema.literal('Infinity')]), + schema: schema.oneOf([schema.number({ min: 0 }), schema.literal('Infinity')]), // Setting to 'Infinity' will disable the countdown. }, 'notifications:lifetime:error': { name: i18n.translate('core.ui_settings.params.notifications.errorLifetimeTitle', { @@ -62,15 +58,11 @@ export const getNotificationsSettings = (): Record => value: 300000, description: i18n.translate('core.ui_settings.params.notifications.errorLifetimeText', { defaultMessage: - 'The time in milliseconds which an error notification will be displayed on-screen for. ' + - 'Setting to {infinityValue} will disable.', - values: { - infinityValue: 'Infinity', - }, + 'The time in milliseconds which an error notification will be displayed on-screen for. ', }), type: 'number', category: ['notifications'], - schema: schema.oneOf([schema.number({ min: 0 }), schema.literal('Infinity')]), + schema: schema.oneOf([schema.number({ min: 0 }), schema.literal('Infinity')]), // Setting to 'Infinity' will disable }, 'notifications:lifetime:warning': { name: i18n.translate('core.ui_settings.params.notifications.warningLifetimeTitle', { @@ -79,15 +71,11 @@ export const getNotificationsSettings = (): Record => value: 10000, description: i18n.translate('core.ui_settings.params.notifications.warningLifetimeText', { defaultMessage: - 'The time in milliseconds which a warning notification will be displayed on-screen for. ' + - 'Setting to {infinityValue} will disable.', - values: { - infinityValue: 'Infinity', - }, + 'The time in milliseconds which a warning notification will be displayed on-screen for. ', }), type: 'number', category: ['notifications'], - schema: schema.oneOf([schema.number({ min: 0 }), schema.literal('Infinity')]), + schema: schema.oneOf([schema.number({ min: 0 }), schema.literal('Infinity')]), // Setting to 'Infinity' will disable }, 'notifications:lifetime:info': { name: i18n.translate('core.ui_settings.params.notifications.infoLifetimeTitle', { @@ -96,15 +84,11 @@ export const getNotificationsSettings = (): Record => value: 5000, description: i18n.translate('core.ui_settings.params.notifications.infoLifetimeText', { defaultMessage: - 'The time in milliseconds which an information notification will be displayed on-screen for. ' + - 'Setting to {infinityValue} will disable.', - values: { - infinityValue: 'Infinity', - }, + 'The time in milliseconds which an information notification will be displayed on-screen for. ', }), type: 'number', category: ['notifications'], - schema: schema.oneOf([schema.number({ min: 0 }), schema.literal('Infinity')]), + schema: schema.oneOf([schema.number({ min: 0 }), schema.literal('Infinity')]), // Setting to 'Infinity' will disable }, }; }; 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 c65a3569448a3..c3c8c480f77f2 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 @@ -116,12 +116,17 @@ kibana_vars=( server.compression.referrerWhitelist server.cors server.cors.origin - server.customResponseHeaders + server.securityResponseHeaders.strictTransportSecurity + server.securityResponseHeaders.xContentTypeOptions + server.securityResponseHeaders.referrerPolicy + server.securityResponseHeaders.permissionsPolicy + server.securityResponseHeaders.disableEmbedding server.customResponseHeaders server.defaultRoute server.host server.keepAliveTimeout server.maxPayloadBytes + server.maxPayload server.name server.port server.rewriteBasePath @@ -224,6 +229,7 @@ kibana_vars=( xpack.maps.enabled xpack.maps.showMapVisualizationTypes xpack.ml.enabled + xpack.observability.unsafe.alertingExperience.enabled xpack.reporting.capture.browser.autoDownload xpack.reporting.capture.browser.chromium.disableSandbox xpack.reporting.capture.browser.chromium.inspect @@ -268,6 +274,7 @@ kibana_vars=( xpack.reporting.queue.timeout xpack.reporting.roles.allow xpack.rollup.enabled + xpack.ruleRegistry.unsafe.write.enabled xpack.searchprofiler.enabled xpack.security.audit.enabled xpack.security.audit.appender.type diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index dde1cb212c536..62e1b24d6d559 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -21,10 +21,6 @@ cp "src/dev/ci_setup/.bazelrc-ci" "$HOME/.bazelrc"; echo "# Appended by src/dev/ci_setup/setup.sh" >> "$HOME/.bazelrc" echo "build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY" >> "$HOME/.bazelrc" -if [[ "$BUILD_TS_REFS_CACHE_ENABLE" != "true" ]]; then - export BUILD_TS_REFS_CACHE_ENABLE=false -fi - ### ### install dependencies ### diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts index 42b278ce6450c..00f298887ee17 100644 --- a/src/dev/typescript/build_ts_refs_cli.ts +++ b/src/dev/typescript/build_ts_refs_cli.ts @@ -21,7 +21,7 @@ const CACHE_WORKING_DIR = Path.resolve(REPO_ROOT, 'data/ts_refs_output_cache'); const TS_ERROR_REF = /\sTS\d{1,6}:\s/; const isTypeFailure = (error: any) => - error.exitCode === 1 && + error.exitCode > 0 && error.stderr === '' && typeof error.stdout === 'string' && TS_ERROR_REF.test(error.stdout); diff --git a/src/plugins/console/server/lib/spec_definitions/js/search.ts b/src/plugins/console/server/lib/spec_definitions/js/search.ts index 686737b96ac83..5a3b37e2e4135 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/search.ts +++ b/src/plugins/console/server/lib/spec_definitions/js/search.ts @@ -16,7 +16,7 @@ export const search = (specService: SpecDefinitionsService) => { // populated by a global rule }, profile: { - __one_of: ['true', 'false'], + __one_of: [true, false], }, aggs: { __template: { @@ -118,6 +118,26 @@ export const search = (specService: SpecDefinitionsService) => { }, }, docvalue_fields: ['{field}'], + fields: { + __one_of: [ + [ + { + __one_of: [ + '{field}', + '*', + { + field: '{field}', + include_unmapped: { + __one_of: ['true', 'false'], + }, + format: '', + }, + ], + }, + ], + '*', + ], + }, collapse: { __template: { field: 'FIELD', @@ -144,6 +164,19 @@ export const search = (specService: SpecDefinitionsService) => { __scope_link: 'GLOBAL.script', }, }, + runtime_mappings: { + __template: { + FIELD: { + type: '', + script: { + // populated by a global rule + }, + }, + }, + '*': { + __scope_link: 'GLOBAL.script', + }, + }, partial_fields: { __template: { NAME: { @@ -160,8 +193,11 @@ export const search = (specService: SpecDefinitionsService) => { }, _source: { __one_of: [ - '{field}', ['{field}'], + '*', + '{field}', + true, + false, { includes: { __one_of: ['{field}', ['{field}']], diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 41335069461fa..54eaf461b73d7 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -11,7 +11,8 @@ "share", "uiActions", "urlForwarding", - "presentationUtil" + "presentationUtil", + "visualizations" ], "optionalPlugins": [ "home", diff --git a/src/plugins/dashboard/public/application/_dashboard_app.scss b/src/plugins/dashboard/public/application/_dashboard_app.scss index 30253afff391f..f6525377cce70 100644 --- a/src/plugins/dashboard/public/application/_dashboard_app.scss +++ b/src/plugins/dashboard/public/application/_dashboard_app.scss @@ -66,4 +66,17 @@ .dshUnsavedListingItem__actions { flex-direction: column; } -} \ No newline at end of file +} + +// Temporary fix for two tone icons to make them monochrome +.dshSolutionToolbar__editorContextMenu--dark { + .euiIcon path { + fill: $euiColorGhost; + } +} + +.dshSolutionToolbar__editorContextMenu--light { + .euiIcon path { + fill: $euiColorInk; + } +} diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index e5281a257ee13..ed68afc5e97b1 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -80,6 +80,7 @@ export async function mountApp({ embeddable: embeddableStart, kibanaLegacy: { dashboardConfig }, savedObjectsTaggingOss, + visualizations, } = pluginsStart; const spacesApi = pluginsStart.spacesOss?.isSpacesAvailable ? pluginsStart.spacesOss : undefined; @@ -123,6 +124,7 @@ export async function mountApp({ visualizeCapabilities: { save: Boolean(coreStart.application.capabilities.visualize?.save) }, storeSearchSession: Boolean(coreStart.application.capabilities.dashboard.storeSearchSession), }, + visualizations, }; const getUrlStateStorage = (history: RouteComponentProps['history']) => diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 9b93f0bbd0711..ff592742488f5 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -49,7 +49,7 @@ export class DashboardContainerFactoryDefinition public readonly getDisplayName = () => { return i18n.translate('dashboard.factory.displayName', { - defaultMessage: 'dashboard', + defaultMessage: 'Dashboard', }); }; 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 4cd3eb13f3609..138d665866af0 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 @@ -287,7 +287,7 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = `

- Add your first panel + Add your first visualization

().services; const [state, setState] = useState({ chromeIsVisible: false }); const [isSaveInProgress, setIsSaveInProgress] = useState(false); + const lensAlias = visualizations.getAliases().find(({ name }) => name === 'lens'); + const quickButtonVisTypes = ['markdown', 'maps']; const stateTransferService = embeddable.getStateTransfer(); + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + + const trackUiMetric = usageCollection?.reportUiCounter.bind( + usageCollection, + DashboardConstants.DASHBOARDS_ID + ); useEffect(() => { const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => { @@ -152,27 +161,36 @@ export function DashboardTopNav({ uiSettings, ]); - const createNew = useCallback(async () => { - const type = 'visualization'; - const factory = embeddable.getEmbeddableFactory(type); + const createNewVisType = useCallback( + (visType?: BaseVisType | VisTypeAlias) => () => { + let path = ''; + let appId = ''; - if (!factory) { - throw new EmbeddableFactoryNotFoundError(type); - } + if (visType) { + if (trackUiMetric) { + trackUiMetric(METRIC_TYPE.CLICK, visType.name); + } - await factory.create({} as EmbeddableInput, dashboardContainer); - }, [dashboardContainer, embeddable]); + if ('aliasPath' in visType) { + appId = visType.aliasApp; + path = visType.aliasPath; + } else { + appId = 'visualize'; + path = `#/create?type=${encodeURIComponent(visType.name)}`; + } + } else { + appId = 'visualize'; + path = '#/create?'; + } - const createNewVisType = useCallback( - (newVisType: string) => async () => { - stateTransferService.navigateToEditor('visualize', { - path: `#/create?type=${encodeURIComponent(newVisType)}`, + stateTransferService.navigateToEditor(appId, { + path, state: { originatingApp: DashboardConstants.DASHBOARDS_ID, }, }); }, - [stateTransferService] + [trackUiMetric, stateTransferService] ); const clearAddPanel = useCallback(() => { @@ -563,38 +581,57 @@ export function DashboardTopNav({ const { TopNavMenu } = navigation.ui; - const quickButtons = [ - { - iconType: 'visText', - createType: i18n.translate('dashboard.solutionToolbar.markdownQuickButtonLabel', { - defaultMessage: 'Markdown', - }), - onClick: createNewVisType('markdown'), - 'data-test-subj': 'dashboardMarkdownQuickButton', - }, - { - iconType: 'controlsHorizontal', - createType: i18n.translate('dashboard.solutionToolbar.inputControlsQuickButtonLabel', { - defaultMessage: 'Input control', - }), - onClick: createNewVisType('input_control_vis'), - 'data-test-subj': 'dashboardInputControlsQuickButton', - }, - ]; + const getVisTypeQuickButton = (visTypeName: string) => { + const visType = + visualizations.get(visTypeName) || + visualizations.getAliases().find(({ name }) => name === visTypeName); + + if (visType) { + if ('aliasPath' in visType) { + const { name, icon, title } = visType as VisTypeAlias; + + return { + iconType: icon, + createType: title, + onClick: createNewVisType(visType as VisTypeAlias), + 'data-test-subj': `dashboardQuickButton${name}`, + isDarkModeEnabled: IS_DARK_THEME, + }; + } else { + const { name, icon, title, titleInWizard } = visType as BaseVisType; + + return { + iconType: icon, + createType: titleInWizard || title, + onClick: createNewVisType(visType as BaseVisType), + 'data-test-subj': `dashboardQuickButton${name}`, + isDarkModeEnabled: IS_DARK_THEME, + }; + } + } + + return; + }; + + const quickButtons = quickButtonVisTypes + .map(getVisTypeQuickButton) + .filter((button) => button) as QuickButtonProps[]; return ( <> + {viewMode !== ViewMode.VIEW ? ( - + {{ primaryActionButton: ( ), @@ -605,6 +642,12 @@ export function DashboardTopNav({ data-test-subj="dashboardAddPanelButton" /> ), + extraButtons: [ + , + ], }} ) : null} diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx new file mode 100644 index 0000000000000..5205f5b294c4f --- /dev/null +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -0,0 +1,255 @@ +/* + * 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, { useCallback } from 'react'; +import { + EuiContextMenu, + EuiContextMenuPanelItemDescriptor, + EuiContextMenuItemIcon, +} from '@elastic/eui'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { i18n } from '@kbn/i18n'; +import { BaseVisType, VisGroups, VisTypeAlias } from '../../../../visualizations/public'; +import { SolutionToolbarPopover } from '../../../../presentation_util/public'; +import { EmbeddableFactoryDefinition, EmbeddableInput } from '../../services/embeddable'; +import { useKibana } from '../../services/kibana_react'; +import { DashboardAppServices } from '../types'; +import { DashboardContainer } from '..'; +import { DashboardConstants } from '../../dashboard_constants'; +import { dashboardReplacePanelAction } from '../../dashboard_strings'; + +interface Props { + /** Dashboard container */ + dashboardContainer: DashboardContainer; + /** Handler for creating new visualization of a specified type */ + createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void; +} + +interface FactoryGroup { + id: string; + appName: string; + icon: EuiContextMenuItemIcon; + panelId: number; + factories: EmbeddableFactoryDefinition[]; +} + +export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { + const { + core, + embeddable, + visualizations, + usageCollection, + uiSettings, + } = useKibana().services; + + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + + const trackUiMetric = usageCollection?.reportUiCounter.bind( + usageCollection, + DashboardConstants.DASHBOARDS_ID + ); + + const createNewAggsBasedVis = useCallback( + (visType?: BaseVisType) => () => + visualizations.showNewVisModal({ + originatingApp: DashboardConstants.DASHBOARDS_ID, + outsideVisualizeApp: true, + showAggsSelection: true, + selectedVisType: visType, + }), + [visualizations] + ); + + const getVisTypesByGroup = (group: VisGroups) => + visualizations + .getByGroup(group) + .sort(({ name: a }: BaseVisType | VisTypeAlias, { name: b }: BaseVisType | VisTypeAlias) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; + }) + .filter(({ hidden }: BaseVisType) => !hidden); + + const promotedVisTypes = getVisTypesByGroup(VisGroups.PROMOTED); + const aggsBasedVisTypes = getVisTypesByGroup(VisGroups.AGGBASED); + const toolVisTypes = getVisTypesByGroup(VisGroups.TOOLS); + const visTypeAliases = visualizations + .getAliases() + .sort(({ promotion: a = false }: VisTypeAlias, { promotion: b = false }: VisTypeAlias) => + a === b ? 0 : a ? -1 : 1 + ); + + const factories = embeddable + ? Array.from(embeddable.getEmbeddableFactories()).filter( + ({ type, isEditable, canCreateNew, isContainerType }) => + isEditable() && !isContainerType && canCreateNew() && type !== 'visualization' + ) + : []; + + const factoryGroupMap: Record = {}; + const ungroupedFactories: EmbeddableFactoryDefinition[] = []; + const aggBasedPanelID = 1; + + let panelCount = 1 + aggBasedPanelID; + + factories.forEach((factory: EmbeddableFactoryDefinition, index) => { + const { grouping } = factory; + + if (grouping) { + grouping.forEach((group) => { + if (factoryGroupMap[group.id]) { + factoryGroupMap[group.id].factories.push(factory); + } else { + factoryGroupMap[group.id] = { + id: group.id, + appName: group.getDisplayName ? group.getDisplayName({ embeddable }) : group.id, + icon: (group.getIconType + ? group.getIconType({ embeddable }) + : 'empty') as EuiContextMenuItemIcon, + factories: [factory], + panelId: panelCount, + }; + + panelCount++; + } + }); + } else { + ungroupedFactories.push(factory); + } + }); + + const getVisTypeMenuItem = (visType: BaseVisType): EuiContextMenuPanelItemDescriptor => { + const { name, title, titleInWizard, description, icon = 'empty', group } = visType; + return { + name: titleInWizard || title, + icon: icon as string, + onClick: + group === VisGroups.AGGBASED ? createNewAggsBasedVis(visType) : createNewVisType(visType), + 'data-test-subj': `visType-${name}`, + toolTipContent: description, + }; + }; + + const getVisTypeAliasMenuItem = ( + visTypeAlias: VisTypeAlias + ): EuiContextMenuPanelItemDescriptor => { + const { name, title, description, icon = 'empty' } = visTypeAlias; + + return { + name: title, + icon, + onClick: createNewVisType(visTypeAlias), + 'data-test-subj': `visType-${name}`, + toolTipContent: description, + }; + }; + + const getEmbeddableFactoryMenuItem = ( + factory: EmbeddableFactoryDefinition + ): EuiContextMenuPanelItemDescriptor => { + const icon = factory?.getIconType ? factory.getIconType() : 'empty'; + + const toolTipContent = factory?.getDescription ? factory.getDescription() : undefined; + + return { + name: factory.getDisplayName(), + icon, + toolTipContent, + onClick: async () => { + if (trackUiMetric) { + trackUiMetric(METRIC_TYPE.CLICK, factory.type); + } + let newEmbeddable; + if (factory.getExplicitInput) { + const explicitInput = await factory.getExplicitInput(); + newEmbeddable = await dashboardContainer.addNewEmbeddable(factory.type, explicitInput); + } else { + newEmbeddable = await factory.create({} as EmbeddableInput, dashboardContainer); + } + + if (newEmbeddable) { + core.notifications.toasts.addSuccess({ + title: dashboardReplacePanelAction.getSuccessMessage( + `'${newEmbeddable.getInput().title}'` || '' + ), + 'data-test-subj': 'addEmbeddableToDashboardSuccess', + }); + } + }, + 'data-test-subj': `createNew-${factory.type}`, + }; + }; + + const aggsPanelTitle = i18n.translate('dashboard.editorMenu.aggBasedGroupTitle', { + defaultMessage: 'Aggregation based', + }); + + const editorMenuPanels = [ + { + id: 0, + items: [ + ...visTypeAliases.map(getVisTypeAliasMenuItem), + ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({ + name: appName, + icon, + panel: panelId, + 'data-test-subj': `dashboardEditorMenu-${id}Group`, + })), + ...ungroupedFactories.map(getEmbeddableFactoryMenuItem), + ...promotedVisTypes.map(getVisTypeMenuItem), + { + name: aggsPanelTitle, + icon: 'visualizeApp', + panel: aggBasedPanelID, + 'data-test-subj': `dashboardEditorAggBasedMenuItem`, + }, + ...toolVisTypes.map(getVisTypeMenuItem), + ], + }, + { + id: aggBasedPanelID, + title: aggsPanelTitle, + items: aggsBasedVisTypes.map(getVisTypeMenuItem), + }, + ...Object.values(factoryGroupMap).map( + ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({ + id: panelId, + title: appName, + items: groupFactories.map(getEmbeddableFactoryMenuItem), + }) + ), + ]; + + return ( + + + + ); +}; diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts index 6415fdfd73ee8..dd291291ce9d6 100644 --- a/src/plugins/dashboard/public/application/types.ts +++ b/src/plugins/dashboard/public/application/types.ts @@ -25,6 +25,7 @@ import { DataPublicPluginStart, IndexPatternsContract } from '../services/data'; import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects'; import { DashboardPanelStorage } from './lib'; import { UrlForwardingStart } from '../../../url_forwarding/public'; +import { VisualizationsStart } from '../../../visualizations/public'; export type DashboardRedirect = (props: RedirectToProps) => void; export type RedirectToProps = @@ -83,4 +84,5 @@ export interface DashboardAppServices { savedObjectsClient: SavedObjectsClientContract; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; savedQueryService: DataPublicPluginStart['query']['savedQueries']; + visualizations: VisualizationsStart; } diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts index 79a59d0cfa605..531ff815312cf 100644 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -377,7 +377,7 @@ export const emptyScreenStrings = { }), getEmptyWidgetTitle: () => i18n.translate('dashboard.emptyWidget.addPanelTitle', { - defaultMessage: 'Add your first panel', + defaultMessage: 'Add your first visualization', }), getEmptyWidgetDescription: () => i18n.translate('dashboard.emptyWidget.addPanelDescription', { diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index e2f52a47455b3..0fad1c51f433a 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -24,6 +24,7 @@ import { PluginInitializerContext, SavedObjectsClientContract, } from '../../../core/public'; +import { VisualizationsStart } from '../../visualizations/public'; import { createKbnUrlTracker } from './services/kibana_utils'; import { UsageCollectionSetup } from './services/usage_collection'; @@ -115,6 +116,7 @@ export interface DashboardStartDependencies { presentationUtil: PresentationUtilPluginStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; spacesOss?: SpacesOssPluginStart; + visualizations: VisualizationsStart; } export type DashboardSetup = void; diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index 3aeaf31c190bd..fbed98a882b0a 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -18,7 +18,7 @@ import { createDashboardSavedObjectType } from './saved_objects'; import { capabilitiesProvider } from './capabilities_provider'; import { DashboardPluginSetup, DashboardPluginStart } from './types'; -import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/server'; +import { EmbeddableSetup } from '../../embeddable/server'; import { UsageCollectionSetup } from '../../usage_collection/server'; import { registerDashboardUsageCollector } from './usage/register_collector'; import { dashboardPersistableStateServiceFactory } from './embeddable/dashboard_container_embeddable_factory'; @@ -28,19 +28,15 @@ interface SetupDeps { usageCollection: UsageCollectionSetup; } -interface StartDeps { - embeddable: EmbeddableStart; -} - export class DashboardPlugin - implements Plugin { + implements Plugin { private readonly logger: Logger; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } - public setup(core: CoreSetup, plugins: SetupDeps) { + public setup(core: CoreSetup, plugins: SetupDeps) { this.logger.debug('dashboard: Setup'); core.savedObjects.registerType( @@ -54,13 +50,9 @@ export class DashboardPlugin registerDashboardUsageCollector(plugins.usageCollection, plugins.embeddable); - (async () => { - const [, startPlugins] = await core.getStartServices(); - - plugins.embeddable.registerEmbeddableFactory( - dashboardPersistableStateServiceFactory(startPlugins.embeddable) - ); - })(); + plugins.embeddable.registerEmbeddableFactory( + dashboardPersistableStateServiceFactory(plugins.embeddable) + ); return {}; } diff --git a/src/plugins/data/common/es_query/kuery/functions/is.test.ts b/src/plugins/data/common/es_query/kuery/functions/is.test.ts index 20de6fc3ae7b8..55aac8189c1d8 100644 --- a/src/plugins/data/common/es_query/kuery/functions/is.test.ts +++ b/src/plugins/data/common/es_query/kuery/functions/is.test.ts @@ -70,6 +70,29 @@ describe('kuery functions', () => { expect(result).toEqual(expected); }); + test('should return an ES match_all query for queries that match all fields and values', () => { + const expected = { + match_all: {}, + }; + const node = nodeTypes.function.buildNode('is', 'n*', '*'); + const result = is.toElasticsearchQuery(node, { + ...indexPattern, + fields: indexPattern.fields.filter((field) => field.name.startsWith('n')), + }); + + expect(result).toEqual(expected); + }); + + test('should return an ES match_all query for * queries without an index pattern', () => { + const expected = { + match_all: {}, + }; + const node = nodeTypes.function.buildNode('is', '*', '*'); + const result = is.toElasticsearchQuery(node); + + expect(result).toEqual(expected); + }); + test('should return an ES multi_match query using default_field when fieldName is null', () => { const expected = { multi_match: { 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 eb89f8a3c1d41..a18ad230c3cae 100644 --- a/src/plugins/data/common/es_query/kuery/functions/is.ts +++ b/src/plugins/data/common/es_query/kuery/functions/is.ts @@ -46,12 +46,21 @@ export function toElasticsearchQuery( const { arguments: [fieldNameArg, valueArg, isPhraseArg], } = node; + + const isExistsQuery = valueArg.type === 'wildcard' && valueArg.value === wildcard.wildcardSymbol; + const isAllFieldsQuery = + fieldNameArg.type === 'wildcard' && fieldNameArg.value === wildcard.wildcardSymbol; + const isMatchAllQuery = isExistsQuery && isAllFieldsQuery; + + if (isMatchAllQuery) { + return { match_all: {} }; + } + const fullFieldNameArg = getFullFieldNameNode( fieldNameArg, indexPattern, context?.nested ? context.nested.path : undefined ); - const fieldName = ast.toElasticsearchQuery(fullFieldNameArg); const value = !isUndefined(valueArg) ? ast.toElasticsearchQuery(valueArg) : valueArg; const type = isPhraseArg.value ? 'phrase' : 'best_fields'; if (fullFieldNameArg.value === null) { @@ -86,13 +95,8 @@ export function toElasticsearchQuery( }); } - const isExistsQuery = valueArg.type === 'wildcard' && (value as any) === '*'; - const isAllFieldsQuery = - (fullFieldNameArg.type === 'wildcard' && ((fieldName as unknown) as string) === '*') || - (fields && indexPattern && fields.length === indexPattern.fields.length); - const isMatchAllQuery = isExistsQuery && isAllFieldsQuery; - - if (isMatchAllQuery) { + // Special case for wildcards where there are no fields or all fields share the same prefix + if (isExistsQuery && (!fields?.length || fields?.length === indexPattern?.fields.length)) { return { match_all: {} }; } diff --git a/src/plugins/data/common/search/aggs/agg_configs.test.ts b/src/plugins/data/common/search/aggs/agg_configs.test.ts index 3ce528e6ed893..28102544ae055 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.test.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.test.ts @@ -342,8 +342,8 @@ describe('AggConfigs', () => { { enabled: true, type: 'max', schema: 'metric', params: { field: 'bytes' } }, ]; - const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); - const topLevelDsl = ac.toDsl(true); + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, hierarchical: true }); + const topLevelDsl = ac.toDsl(); const buckets = ac.bySchemaName('buckets'); const metrics = ac.bySchemaName('metrics'); @@ -412,8 +412,8 @@ describe('AggConfigs', () => { }, ]; - const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); - const topLevelDsl = ac.toDsl(true)['2']; + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, hierarchical: true }); + const topLevelDsl = ac.toDsl()['2']; expect(Object.keys(topLevelDsl.aggs)).toContain('1'); expect(Object.keys(topLevelDsl.aggs)).toContain('1-bucket'); diff --git a/src/plugins/data/common/search/aggs/agg_configs.ts b/src/plugins/data/common/search/aggs/agg_configs.ts index 4d5d49754387d..2932ef7325aed 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.ts @@ -43,6 +43,7 @@ function parseParentAggs(dslLvlCursor: any, dsl: any) { export interface AggConfigsOptions { typesRegistry: AggTypesRegistryStart; + hierarchical?: boolean; } export type CreateAggConfigParams = Assign; @@ -65,6 +66,8 @@ export class AggConfigs { public indexPattern: IndexPattern; public timeRange?: TimeRange; public timeFields?: string[]; + public hierarchical?: boolean = false; + private readonly typesRegistry: AggTypesRegistryStart; aggs: IAggConfig[]; @@ -80,6 +83,7 @@ export class AggConfigs { this.aggs = []; this.indexPattern = indexPattern; + this.hierarchical = opts.hierarchical; configStates.forEach((params: any) => this.createAggConfig(params)); } @@ -174,12 +178,12 @@ export class AggConfigs { return true; } - toDsl(hierarchical: boolean = false): Record { + toDsl(): Record { const dslTopLvl = {}; let dslLvlCursor: Record; let nestedMetrics: Array<{ config: AggConfig; dsl: Record }> | []; - if (hierarchical) { + if (this.hierarchical) { // collect all metrics, and filter out the ones that we won't be copying nestedMetrics = this.aggs .filter(function (agg) { diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index 33fdc45a605b7..f0f3912bf64fe 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -13,12 +13,23 @@ import { ISearchSource } from 'src/plugins/data/public'; import { DatatableColumnType, SerializedFieldFormat } from 'src/plugins/expressions/common'; import type { RequestAdapter } from 'src/plugins/inspector/common'; +import { estypes } from '@elastic/elasticsearch'; import { initParams } from './agg_params'; import { AggConfig } from './agg_config'; import { IAggConfigs } from './agg_configs'; import { BaseParamType } from './param_types/base'; import { AggParamType } from './param_types/agg'; +type PostFlightRequestFn = ( + resp: estypes.SearchResponse, + aggConfigs: IAggConfigs, + aggConfig: TAggConfig, + searchSource: ISearchSource, + inspectorRequestAdapter?: RequestAdapter, + abortSignal?: AbortSignal, + searchSessionId?: string +) => Promise>; + export interface AggTypeConfig< TAggConfig extends AggConfig = AggConfig, TParam extends AggParamType = AggParamType @@ -40,15 +51,7 @@ export interface AggTypeConfig< customLabels?: boolean; json?: boolean; decorateAggConfig?: () => any; - postFlightRequest?: ( - resp: any, - aggConfigs: IAggConfigs, - aggConfig: TAggConfig, - searchSource: ISearchSource, - inspectorRequestAdapter?: RequestAdapter, - abortSignal?: AbortSignal, - searchSessionId?: string - ) => Promise; + postFlightRequest?: PostFlightRequestFn; getSerializedFormat?: (agg: TAggConfig) => SerializedFieldFormat; getValue?: (agg: TAggConfig, bucket: any) => any; getKey?: (bucket: any, key: any, agg: TAggConfig) => any; @@ -188,15 +191,7 @@ export class AggType< * @param searchSessionId - searchSessionId to be used for grouping requests into a single search session * @return {Promise} */ - postFlightRequest: ( - resp: any, - aggConfigs: IAggConfigs, - aggConfig: TAggConfig, - searchSource: ISearchSource, - inspectorRequestAdapter?: RequestAdapter, - abortSignal?: AbortSignal, - searchSessionId?: string - ) => Promise; + postFlightRequest: PostFlightRequestFn; /** * Get the serialized format for the values produced by this agg type, * overridden by several metrics that always output a simple number. diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts index 56e720d237c45..2aa0d346afe34 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts @@ -433,7 +433,7 @@ describe('Terms Agg Other bucket helper', () => { aggConfigs.aggs[0] as IBucketAggConfig, otherAggConfig() ); - expect(mergedResponse.aggregations['1'].buckets[3].key).toEqual('__other__'); + expect((mergedResponse!.aggregations!['1'] as any).buckets[3].key).toEqual('__other__'); } }); @@ -455,7 +455,7 @@ describe('Terms Agg Other bucket helper', () => { otherAggConfig() ); - expect(mergedResponse.aggregations['1'].buckets[1]['2'].buckets[3].key).toEqual( + expect((mergedResponse!.aggregations!['1'] as any).buckets[1]['2'].buckets[3].key).toEqual( '__other__' ); } @@ -471,7 +471,7 @@ describe('Terms Agg Other bucket helper', () => { aggConfigs.aggs[0] as IBucketAggConfig ); expect( - updatedResponse.aggregations['1'].buckets.find( + (updatedResponse!.aggregations!['1'] as any).buckets.find( (bucket: Record) => bucket.key === '__missing__' ) ).toBeDefined(); diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts index 742615bc49d8f..6230ae897b170 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts @@ -7,6 +7,7 @@ */ import { isNumber, keys, values, find, each, cloneDeep, flatten } from 'lodash'; +import { estypes } from '@elastic/elasticsearch'; import { buildExistsFilter, buildPhrasesFilter, buildQueryFromFilters } from '../../../../common'; import { AggGroupNames } from '../agg_groups'; import { IAggConfigs } from '../agg_configs'; @@ -42,7 +43,7 @@ const getNestedAggDSL = (aggNestedDsl: Record, startFromAggId: stri */ const getAggResultBuckets = ( aggConfigs: IAggConfigs, - response: any, + response: estypes.SearchResponse['aggregations'], aggWithOtherBucket: IBucketAggConfig, key: string ) => { @@ -72,8 +73,8 @@ const getAggResultBuckets = ( } } } - if (responseAgg[aggWithOtherBucket.id]) { - return responseAgg[aggWithOtherBucket.id].buckets; + if (responseAgg?.[aggWithOtherBucket.id]) { + return (responseAgg[aggWithOtherBucket.id] as any).buckets; } return []; }; @@ -235,11 +236,11 @@ export const buildOtherBucketAgg = ( export const mergeOtherBucketAggResponse = ( aggsConfig: IAggConfigs, - response: any, + response: estypes.SearchResponse, otherResponse: any, otherAgg: IBucketAggConfig, requestAgg: Record -) => { +): estypes.SearchResponse => { const updatedResponse = cloneDeep(response); each(otherResponse.aggregations['other-filter'].buckets, (bucket, key) => { if (!bucket.doc_count || key === undefined) return; @@ -276,7 +277,7 @@ export const mergeOtherBucketAggResponse = ( }; export const updateMissingBucket = ( - response: any, + response: estypes.SearchResponse, aggConfigs: IAggConfigs, agg: IBucketAggConfig ) => { diff --git a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts index e694591c7b33d..6fbaddb09b226 100644 --- a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts @@ -109,7 +109,7 @@ describe('TimeBuckets', () => { } }); - test('setInterval/getInterval - intreval is a "auto"', () => { + test('setInterval/getInterval - interval is a "auto"', () => { const timeBuckets = new TimeBuckets(timeBucketConfig); timeBuckets.setInterval(autoInterval); const interval = timeBuckets.getInterval(); @@ -120,6 +120,16 @@ describe('TimeBuckets', () => { expect(interval.expression).toEqual('0ms'); }); + test('setInterval/getInterval - interval is a "auto" (useNormalizedEsInterval is false)', () => { + const timeBuckets = new TimeBuckets(timeBucketConfig); + timeBuckets.setInterval(autoInterval); + const interval = timeBuckets.getInterval(false); + + expect(interval.esValue).toEqual(0); + expect(interval.esUnit).toEqual('ms'); + expect(interval.expression).toEqual('0ms'); + }); + test('getScaledDateFormat', () => { const timeBuckets = new TimeBuckets(timeBucketConfig); timeBuckets.setInterval('20m'); diff --git a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.ts b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.ts index ac58cea60a6ef..12c6d39ad905a 100644 --- a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.ts +++ b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.ts @@ -267,9 +267,10 @@ export class TimeBuckets { originalUnit = splitStringInterval(this._originalInterval!)?.unit; } - const esInterval = useNormalizedEsInterval - ? convertDurationToNormalizedEsInterval(interval, originalUnit) - : convertIntervalToEsInterval(String(this._originalInterval)); + const esInterval = + useNormalizedEsInterval || !this._originalInterval + ? convertDurationToNormalizedEsInterval(interval, originalUnit) + : convertIntervalToEsInterval(this._originalInterval); const prettyUnits = moment.normalizeUnits(esInterval.unit); diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts index 77c9c6e391c0a..03cf14a577a50 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.ts @@ -101,25 +101,21 @@ export const getTermsBucketAgg = () => nestedSearchSource.setField('aggs', filterAgg); - const requestResponder = inspectorRequestAdapter?.start( - i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', { - defaultMessage: 'Other bucket', - }), - { - description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', { - defaultMessage: - 'This request counts the number of documents that fall ' + - 'outside the criterion of the data buckets.', - }), - searchSessionId, - } - ); - const response = await nestedSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, - requestResponder, + inspector: { + adapter: inspectorRequestAdapter, + title: i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', { + defaultMessage: 'Other bucket', + }), + description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', { + defaultMessage: + 'This request counts the number of documents that fall ' + + 'outside the criterion of the data buckets.', + }), + }, }) .toPromise(); diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index c2566535916a8..b30e5740fa3fb 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -9,7 +9,7 @@ import type { MockedKeys } from '@kbn/utility-types/jest'; import type { Filter } from '../../../es_query'; import type { IndexPattern } from '../../../index_patterns'; -import type { IAggConfig, IAggConfigs } from '../../aggs'; +import type { IAggConfigs } from '../../aggs'; import type { ISearchSource } from '../../search_source'; import { searchSourceCommonMock } from '../../search_source/mocks'; @@ -38,7 +38,6 @@ describe('esaggs expression function - public', () => { filters: undefined, indexPattern: ({ id: 'logstash-*' } as unknown) as jest.Mocked, inspectorAdapters: {}, - metricsAtAllLevels: false, partialRows: false, query: undefined, searchSessionId: 'abc123', @@ -76,21 +75,7 @@ describe('esaggs expression function - public', () => { test('setField(aggs)', async () => { expect(searchSource.setField).toHaveBeenCalledTimes(5); - expect(typeof (searchSource.setField as jest.Mock).mock.calls[2][1]).toBe('function'); - expect((searchSource.setField as jest.Mock).mock.calls[2][1]()).toEqual( - mockParams.aggs.toDsl() - ); - expect(mockParams.aggs.toDsl).toHaveBeenCalledWith(mockParams.metricsAtAllLevels); - - // make sure param is passed through - jest.clearAllMocks(); - await handleRequest({ - ...mockParams, - metricsAtAllLevels: true, - }); - searchSource = await mockParams.searchSourceService.create(); - (searchSource.setField as jest.Mock).mock.calls[2][1](); - expect(mockParams.aggs.toDsl).toHaveBeenCalledWith(true); + expect((searchSource.setField as jest.Mock).mock.calls[2][1]).toEqual(mockParams.aggs); }); test('setField(filter)', async () => { @@ -133,36 +118,24 @@ describe('esaggs expression function - public', () => { test('calls searchSource.fetch', async () => { await handleRequest(mockParams); const searchSource = await mockParams.searchSourceService.create(); + expect(searchSource.fetch$).toHaveBeenCalledWith({ abortSignal: mockParams.abortSignal, sessionId: mockParams.searchSessionId, + inspector: { + title: 'Data', + description: 'This request queries Elasticsearch to fetch the data for the visualization.', + adapter: undefined, + }, }); }); - test('calls agg.postFlightRequest if it exiests and agg is enabled', async () => { - mockParams.aggs.aggs[0].enabled = true; - await handleRequest(mockParams); - expect(mockParams.aggs.aggs[0].type.postFlightRequest).toHaveBeenCalledTimes(1); - - // ensure it works if the function doesn't exist - jest.clearAllMocks(); - mockParams.aggs.aggs[0] = ({ type: { name: 'count' } } as unknown) as IAggConfig; - expect(async () => await handleRequest(mockParams)).not.toThrowError(); - }); - - test('should skip agg.postFlightRequest call if the agg is disabled', async () => { - mockParams.aggs.aggs[0].enabled = false; - await handleRequest(mockParams); - expect(mockParams.aggs.aggs[0].type.postFlightRequest).toHaveBeenCalledTimes(0); - }); - test('tabifies response data', async () => { await handleRequest(mockParams); expect(tabifyAggResponse).toHaveBeenCalledWith( mockParams.aggs, {}, { - metricsAtAllLevels: mockParams.metricsAtAllLevels, partialRows: mockParams.partialRows, timeRange: mockParams.timeRange, } diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index 5620698a47538..173b2067cad6b 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -40,28 +40,12 @@ export interface RequestHandlerParams { getNow?: () => Date; } -function getRequestMainResponder(inspectorAdapters: Adapters, searchSessionId?: string) { - return inspectorAdapters.requests?.start( - i18n.translate('data.functions.esaggs.inspector.dataRequest.title', { - defaultMessage: 'Data', - }), - { - description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', { - defaultMessage: - 'This request queries Elasticsearch to fetch the data for the visualization.', - }), - searchSessionId, - } - ); -} - export const handleRequest = async ({ abortSignal, aggs, filters, indexPattern, inspectorAdapters, - metricsAtAllLevels, partialRows, query, searchSessionId, @@ -100,9 +84,7 @@ export const handleRequest = async ({ }, }); - requestSearchSource.setField('aggs', function () { - return aggs.toDsl(metricsAtAllLevels); - }); + requestSearchSource.setField('aggs', aggs); requestSearchSource.onRequestStart((paramSearchSource, options) => { return aggs.onSearchRequestStart(paramSearchSource, options); @@ -128,35 +110,27 @@ export const handleRequest = async ({ requestSearchSource.setField('query', query); inspectorAdapters.requests?.reset(); - const requestResponder = getRequestMainResponder(inspectorAdapters, searchSessionId); - const response$ = await requestSearchSource.fetch$({ - abortSignal, - sessionId: searchSessionId, - requestResponder, - }); - - // Note that rawResponse is not deeply cloned here, so downstream applications using courier - // must take care not to mutate it, or it could have unintended side effects, e.g. displaying - // response data incorrectly in the inspector. - let response = await response$.toPromise(); - for (const agg of aggs.aggs) { - if (agg.enabled && typeof agg.type.postFlightRequest === 'function') { - response = await agg.type.postFlightRequest( - response, - aggs, - agg, - requestSearchSource, - inspectorAdapters.requests, - abortSignal, - searchSessionId - ); - } - } + const response = await requestSearchSource + .fetch$({ + abortSignal, + sessionId: searchSessionId, + inspector: { + adapter: inspectorAdapters.requests, + title: i18n.translate('data.functions.esaggs.inspector.dataRequest.title', { + defaultMessage: 'Data', + }), + description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', { + defaultMessage: + 'This request queries Elasticsearch to fetch the data for the visualization.', + }), + }, + }) + .toPromise(); const parsedTimeRange = timeRange ? calculateBounds(timeRange, { forceNow }) : null; const tabifyParams = { - metricsAtAllLevels, + metricsAtAllLevels: aggs.hierarchical, partialRows, timeRange: parsedTimeRange ? { from: parsedTimeRange.min, to: parsedTimeRange.max, timeFields: allTimeFields } diff --git a/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts b/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts index 24507a7e13058..e5a3acc23eee8 100644 --- a/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts +++ b/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts @@ -50,7 +50,7 @@ export function getRequestInspectorStats(searchSource: ISearchSource) { /** @public */ export function getResponseInspectorStats( - resp: estypes.SearchResponse, + resp?: estypes.SearchResponse, searchSource?: ISearchSource ) { const lastRequest = diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 3726e5d0c33e8..012fc5257397b 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -11,6 +11,10 @@ import { IndexPattern } from '../../index_patterns'; import { GetConfigFn } from '../../types'; import { fetchSoon } from './legacy'; import { SearchSource, SearchSourceDependencies, SortDirection } from './'; +import { AggConfigs, AggTypesRegistryStart } from '../../'; +import { mockAggTypesRegistry } from '../aggs/test_helpers'; +import { RequestResponder } from 'src/plugins/inspector/common'; +import { switchMap } from 'rxjs/operators'; jest.mock('./legacy', () => ({ fetchSoon: jest.fn().mockResolvedValue({}), @@ -39,6 +43,21 @@ const indexPattern2 = ({ getSourceFiltering: () => mockSource2, } as unknown) as IndexPattern; +const fields3 = [{ name: 'foo-bar' }, { name: 'field1' }, { name: 'field2' }]; +const indexPattern3 = ({ + title: 'foo', + fields: { + getByName: (name: string) => { + return fields3.find((field) => field.name === name); + }, + filter: () => { + return fields3; + }, + }, + getComputedFields, + getSourceFiltering: () => mockSource, +} as unknown) as IndexPattern; + const runtimeFieldDef = { type: 'keyword', script: { @@ -61,8 +80,8 @@ describe('SearchSource', () => { .fn() .mockReturnValue( of( - { rawResponse: { isPartial: true, isRunning: true } }, - { rawResponse: { isPartial: false, isRunning: false } } + { rawResponse: { test: 1 }, isPartial: true, isRunning: true }, + { rawResponse: { test: 2 }, isPartial: false, isRunning: false } ) ); @@ -81,17 +100,19 @@ describe('SearchSource', () => { describe('#getField()', () => { test('gets the value for the property', () => { - searchSource.setField('aggs', 5); - expect(searchSource.getField('aggs')).toBe(5); + searchSource.setField('aggs', { i: 5 }); + expect(searchSource.getField('aggs')).toStrictEqual({ i: 5 }); }); }); describe('#getFields()', () => { test('gets the value for the property', () => { - searchSource.setField('aggs', 5); + searchSource.setField('aggs', { i: 5 }); expect(searchSource.getFields()).toMatchInlineSnapshot(` Object { - "aggs": 5, + "aggs": Object { + "i": 5, + }, } `); }); @@ -100,7 +121,7 @@ describe('SearchSource', () => { describe('#removeField()', () => { test('remove property', () => { searchSource = new SearchSource({}, searchSourceDependencies); - searchSource.setField('aggs', 5); + searchSource.setField('aggs', { i: 5 }); searchSource.removeField('aggs'); expect(searchSource.getField('aggs')).toBeFalsy(); }); @@ -108,8 +129,20 @@ describe('SearchSource', () => { describe('#setField() / #flatten', () => { test('sets the value for the property', () => { - searchSource.setField('aggs', 5); - expect(searchSource.getField('aggs')).toBe(5); + searchSource.setField('aggs', { i: 5 }); + expect(searchSource.getField('aggs')).toStrictEqual({ i: 5 }); + }); + + test('sets the value for the property with AggConfigs', () => { + const typesRegistry = mockAggTypesRegistry(); + + const ac = new AggConfigs(indexPattern3, [{ type: 'avg', params: { field: 'field1' } }], { + typesRegistry, + }); + + searchSource.setField('aggs', ac); + const request = searchSource.getSearchRequestBody(); + expect(request.aggs).toStrictEqual({ '1': { avg: { field: 'field1' } } }); }); describe('computed fields handling', () => { @@ -385,10 +418,16 @@ describe('SearchSource', () => { docvalueFields: [], }), } as unknown) as IndexPattern); - searchSource.setField('fields', ['hello', 'foo']); - + searchSource.setField('fields', [ + 'hello', + 'foo-bar', + 'foo--bar', + 'fooo', + 'somethingfoo', + 'xxfxxoxxo', + ]); const request = searchSource.getSearchRequestBody(); - expect(request.fields).toEqual(['hello']); + expect(request.fields).toEqual(['hello', 'fooo', 'somethingfoo', 'xxfxxoxxo']); }); test('request all fields from index pattern except the ones specified with source filters', async () => { @@ -631,7 +670,7 @@ describe('SearchSource', () => { const fn = jest.fn(); searchSource.onRequestStart(fn); const options = {}; - await searchSource.fetch(options); + await searchSource.fetch$(options).toPromise(); expect(fn).toBeCalledWith(searchSource, options); }); @@ -644,7 +683,7 @@ describe('SearchSource', () => { const parentFn = jest.fn(); parent.onRequestStart(parentFn); const options = {}; - await searchSource.fetch(options); + await searchSource.fetch$(options).toPromise(); expect(fn).toBeCalledWith(searchSource, options); expect(parentFn).not.toBeCalled(); @@ -664,69 +703,13 @@ describe('SearchSource', () => { const parentFn = jest.fn(); parent.onRequestStart(parentFn); const options = {}; - await searchSource.fetch(options); + await searchSource.fetch$(options).toPromise(); expect(fn).toBeCalledWith(searchSource, options); expect(parentFn).toBeCalledWith(searchSource, options); }); }); - describe('#legacy fetch()', () => { - beforeEach(() => { - searchSourceDependencies = { - ...searchSourceDependencies, - getConfig: jest.fn(() => { - return true; // batchSearches = true - }) as GetConfigFn, - }; - }); - - test('should call msearch', async () => { - searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); - const options = {}; - await searchSource.fetch(options); - expect(fetchSoon).toBeCalledTimes(1); - }); - }); - - describe('#search service fetch()', () => { - test('should call msearch', async () => { - searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); - const options = {}; - - await searchSource.fetch(options); - expect(mockSearchMethod).toBeCalledTimes(1); - }); - - test('should return partial results', (done) => { - searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); - const options = {}; - - const next = jest.fn(); - const complete = () => { - expect(next).toBeCalledTimes(2); - expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "isPartial": true, - "isRunning": true, - }, - ] - `); - expect(next.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - Object { - "isPartial": false, - "isRunning": false, - }, - ] - `); - done(); - }; - searchSource.fetch$(options).subscribe({ next, complete }); - }); - }); - describe('#serialize', () => { test('should reference index patterns', () => { const indexPattern123 = { id: '123' } as IndexPattern; @@ -884,4 +867,373 @@ describe('SearchSource', () => { ); }); }); + + describe('fetch$', () => { + describe('#legacy fetch()', () => { + beforeEach(() => { + searchSourceDependencies = { + ...searchSourceDependencies, + getConfig: jest.fn(() => { + return true; // batchSearches = true + }) as GetConfigFn, + }; + }); + + test('should call msearch', async () => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + await searchSource.fetch$(options).toPromise(); + expect(fetchSoon).toBeCalledTimes(1); + }); + }); + + describe('responses', () => { + test('should return partial results', async () => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const complete = jest.fn(); + const res$ = searchSource.fetch$(options); + res$.subscribe({ next, complete }); + await res$.toPromise(); + + expect(next).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(1); + expect(next.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "test": 1, + }, + ] + `); + expect(next.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "test": 2, + }, + ] + `); + }); + + test('shareReplays result', async () => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const complete = jest.fn(); + const next2 = jest.fn(); + const complete2 = jest.fn(); + const res$ = searchSource.fetch$(options); + res$.subscribe({ next, complete }); + res$.subscribe({ next: next2, complete: complete2 }); + await res$.toPromise(); + + expect(next).toBeCalledTimes(2); + expect(next2).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(1); + expect(complete2).toBeCalledTimes(1); + expect(searchSourceDependencies.search).toHaveBeenCalledTimes(1); + }); + + test('should emit error on empty response', async () => { + searchSourceDependencies.search = mockSearchMethod = jest + .fn() + .mockReturnValue( + of({ rawResponse: { test: 1 }, isPartial: true, isRunning: true }, undefined) + ); + + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const error = jest.fn(); + const complete = jest.fn(); + const res$ = searchSource.fetch$(options); + res$.subscribe({ next, error, complete }); + await res$.toPromise().catch((e) => {}); + + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + expect(next.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "test": 1, + }, + ] + `); + expect(error.mock.calls[0][0]).toBe(undefined); + }); + }); + + describe('inspector', () => { + let requestResponder: RequestResponder; + beforeEach(() => { + requestResponder = ({ + stats: jest.fn(), + ok: jest.fn(), + error: jest.fn(), + json: jest.fn(), + } as unknown) as RequestResponder; + }); + + test('calls inspector if provided', async () => { + const options = { + inspector: { + title: 'a', + adapter: { + start: jest.fn().mockReturnValue(requestResponder), + } as any, + }, + }; + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + await searchSource.fetch$(options).toPromise(); + + expect(options.inspector.adapter.start).toBeCalledTimes(1); + expect(requestResponder.error).not.toBeCalled(); + expect(requestResponder.json).toBeCalledTimes(1); + expect(requestResponder.ok).toBeCalledTimes(1); + // First and last + expect(requestResponder.stats).toBeCalledTimes(2); + }); + + test('calls inspector only once, with multiple subs (shareReplay)', async () => { + const options = { + inspector: { + title: 'a', + adapter: { + start: jest.fn().mockReturnValue(requestResponder), + } as any, + }, + }; + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + const res$ = searchSource.fetch$(options); + + const complete1 = jest.fn(); + const complete2 = jest.fn(); + + res$.subscribe({ + complete: complete1, + }); + res$.subscribe({ + complete: complete2, + }); + + await res$.toPromise(); + + expect(complete1).toBeCalledTimes(1); + expect(complete2).toBeCalledTimes(1); + expect(options.inspector.adapter.start).toBeCalledTimes(1); + }); + + test('calls error on inspector', async () => { + const options = { + inspector: { + title: 'a', + adapter: { + start: jest.fn().mockReturnValue(requestResponder), + } as any, + }, + }; + + searchSourceDependencies.search = jest.fn().mockReturnValue(of(Promise.reject('aaaaa'))); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + await searchSource + .fetch$(options) + .toPromise() + .catch(() => {}); + + expect(options.inspector.adapter.start).toBeCalledTimes(1); + expect(requestResponder.json).toBeCalledTimes(1); + expect(requestResponder.error).toBeCalledTimes(1); + expect(requestResponder.ok).toBeCalledTimes(0); + expect(requestResponder.stats).toBeCalledTimes(0); + }); + }); + + describe('postFlightRequest', () => { + let fetchSub: any; + + function getAggConfigs(typesRegistry: AggTypesRegistryStart, enabled: boolean) { + return new AggConfigs( + indexPattern3, + [ + { + type: 'avg', + enabled, + params: { field: 'field1' }, + }, + ], + { + typesRegistry, + } + ); + } + + beforeEach(() => { + fetchSub = { + next: jest.fn(), + complete: jest.fn(), + error: jest.fn(), + }; + }); + + test('doesnt call any post flight requests if disabled', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn(); + const ac = getAggConfigs(typesRegistry, false); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', ac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + await fetch$.toPromise(); + + expect(fetchSub.next).toHaveBeenCalledTimes(2); + expect(fetchSub.complete).toHaveBeenCalledTimes(1); + expect(fetchSub.error).toHaveBeenCalledTimes(0); + + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(0); + }); + + test('doesnt call any post flight if searchsource has error', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn(); + const ac = getAggConfigs(typesRegistry, true); + + searchSourceDependencies.search = jest.fn().mockImplementation(() => + of(1).pipe( + switchMap((r) => { + throw r; + }) + ) + ); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', ac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + await fetch$.toPromise().catch((e) => {}); + + expect(fetchSub.next).toHaveBeenCalledTimes(0); + expect(fetchSub.complete).toHaveBeenCalledTimes(0); + expect(fetchSub.error).toHaveBeenNthCalledWith(1, 1); + + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(0); + }); + + test('calls post flight requests, fires 1 extra response, returns last response', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn().mockResolvedValue({ + other: 5, + }); + + const allac = new AggConfigs( + indexPattern3, + [ + { + type: 'avg', + enabled: true, + params: { field: 'field1' }, + }, + { + type: 'avg', + enabled: true, + params: { field: 'field2' }, + }, + { + type: 'avg', + enabled: true, + params: { field: 'foo-bar' }, + }, + ], + { + typesRegistry, + } + ); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', allac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + + const resp = await fetch$.toPromise(); + + expect(fetchSub.next).toHaveBeenCalledTimes(3); + expect(fetchSub.complete).toHaveBeenCalledTimes(1); + expect(fetchSub.error).toHaveBeenCalledTimes(0); + expect(resp).toStrictEqual({ other: 5 }); + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(3); + }); + + test('calls post flight requests only once, with multiple subs (shareReplay)', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn().mockResolvedValue({ + other: 5, + }); + + const allac = new AggConfigs( + indexPattern3, + [ + { + type: 'avg', + enabled: true, + params: { field: 'field1' }, + }, + ], + { + typesRegistry, + } + ); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', allac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + + const fetchSub2 = { + next: jest.fn(), + complete: jest.fn(), + error: jest.fn(), + }; + fetch$.subscribe(fetchSub2); + + await fetch$.toPromise(); + + expect(fetchSub.next).toHaveBeenCalledTimes(3); + expect(fetchSub.complete).toHaveBeenCalledTimes(1); + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(1); + }); + + test('calls post flight requests, handles error', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn().mockRejectedValue(undefined); + const ac = getAggConfigs(typesRegistry, true); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', ac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + + await fetch$.toPromise().catch(() => {}); + + expect(fetchSub.next).toHaveBeenCalledTimes(2); + expect(fetchSub.complete).toHaveBeenCalledTimes(0); + expect(fetchSub.error).toHaveBeenCalledTimes(1); + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index e1e7a8292d677..6f34d5ce1f29c 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -60,12 +60,22 @@ import { setWith } from '@elastic/safer-lodash-set'; import { uniqueId, keyBy, pick, difference, isFunction, isEqual, uniqWith, isObject } from 'lodash'; -import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators'; -import { defer, from } from 'rxjs'; +import { + catchError, + finalize, + first, + last, + map, + shareReplay, + switchMap, + tap, +} from 'rxjs/operators'; +import { defer, EMPTY, from, Observable } from 'rxjs'; +import { estypes } from '@elastic/elasticsearch'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patterns'; -import { ISearchGeneric, ISearchOptions } from '../..'; +import { AggConfigs, ISearchGeneric, ISearchOptions } from '../..'; import type { ISearchSource, SearchFieldValue, @@ -75,7 +85,15 @@ import type { import { FetchHandlers, RequestFailure, getSearchParamsFromRequest, SearchRequest } from './fetch'; import { getRequestInspectorStats, getResponseInspectorStats } from './inspect'; -import { getEsQueryConfig, buildEsQuery, Filter, UI_SETTINGS } from '../../../common'; +import { + getEsQueryConfig, + buildEsQuery, + Filter, + UI_SETTINGS, + isErrorResponse, + isPartialResponse, + IKibanaSearchResponse, +} from '../../../common'; import { getHighlightRequest } from '../../../common/field_formats'; import { fetchSoon } from './legacy'; import { extractReferences } from './extract_references'; @@ -256,10 +274,8 @@ export class SearchSource { */ fetch$(options: ISearchOptions = {}) { const { getConfig } = this.dependencies; - return defer(() => this.requestIsStarting(options)).pipe( - tap(() => { - options.requestResponder?.stats(getRequestInspectorStats(this)); - }), + + const s$ = defer(() => this.requestIsStarting(options)).pipe( switchMap(() => { const searchRequest = this.flatten(); this.history = [searchRequest]; @@ -273,21 +289,14 @@ export class SearchSource { }), tap((response) => { // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved - if ((response as any).error) { + if (!response || (response as any).error) { throw new RequestFailure(null, response); - } else { - options.requestResponder?.stats(getResponseInspectorStats(response, this)); - options.requestResponder?.ok({ json: response }); } }), - catchError((e) => { - options.requestResponder?.error({ json: e }); - throw e; - }), - finalize(() => { - options.requestResponder?.json(this.getSearchRequestBody()); - }) + shareReplay() ); + + return this.inspectSearch(s$, options); } /** @@ -328,9 +337,96 @@ export class SearchSource { * PRIVATE APIS ******/ + private inspectSearch(s$: Observable>, options: ISearchOptions) { + const { id, title, description, adapter } = options.inspector || { title: '' }; + + const requestResponder = adapter?.start(title, { + id, + description, + searchSessionId: options.sessionId, + }); + + const trackRequestBody = () => { + try { + requestResponder?.json(this.getSearchRequestBody()); + } catch (e) {} // eslint-disable-line no-empty + }; + + // Track request stats on first emit, swallow errors + const first$ = s$ + .pipe( + first(undefined, null), + tap(() => { + requestResponder?.stats(getRequestInspectorStats(this)); + trackRequestBody(); + }), + catchError(() => { + trackRequestBody(); + return EMPTY; + }), + finalize(() => { + first$.unsubscribe(); + }) + ) + .subscribe(); + + // Track response stats on last emit, as well as errors + const last$ = s$ + .pipe( + catchError((e) => { + requestResponder?.error({ json: e }); + return EMPTY; + }), + last(undefined, null), + tap((finalResponse) => { + if (finalResponse) { + requestResponder?.stats(getResponseInspectorStats(finalResponse, this)); + requestResponder?.ok({ json: finalResponse }); + } + }), + finalize(() => { + last$.unsubscribe(); + }) + ) + .subscribe(); + + return s$; + } + + private hasPostFlightRequests() { + const aggs = this.getField('aggs'); + if (aggs instanceof AggConfigs) { + return aggs.aggs.some( + (agg) => agg.enabled && typeof agg.type.postFlightRequest === 'function' + ); + } else { + return false; + } + } + + private async fetchOthers(response: estypes.SearchResponse, options: ISearchOptions) { + const aggs = this.getField('aggs'); + if (aggs instanceof AggConfigs) { + for (const agg of aggs.aggs) { + if (agg.enabled && typeof agg.type.postFlightRequest === 'function') { + response = await agg.type.postFlightRequest( + response, + aggs, + agg, + this, + options.inspector?.adapter, + options.abortSignal, + options.sessionId + ); + } + } + return response; + } + } + /** * Run a search using the search service - * @return {Promise>} + * @return {Observable>} */ private fetchSearch$(searchRequest: SearchRequest, options: ISearchOptions) { const { search, getConfig, onResponse } = this.dependencies; @@ -340,6 +436,43 @@ export class SearchSource { }); return search({ params, indexType: searchRequest.indexType }, options).pipe( + switchMap((response) => { + return new Observable>((obs) => { + if (isErrorResponse(response)) { + obs.error(response); + } else if (isPartialResponse(response)) { + obs.next(response); + } else { + if (!this.hasPostFlightRequests()) { + obs.next(response); + obs.complete(); + } else { + // Treat the complete response as partial, then run the postFlightRequests. + obs.next({ + ...response, + isPartial: true, + isRunning: true, + }); + const sub = from(this.fetchOthers(response.rawResponse, options)).subscribe({ + next: (responseWithOther) => { + obs.next({ + ...response, + rawResponse: responseWithOther, + }); + }, + error: (e) => { + obs.error(e); + sub.unsubscribe(); + }, + complete: () => { + obs.complete(); + sub.unsubscribe(); + }, + }); + } + } + }); + }), map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) ); } @@ -452,6 +585,12 @@ export class SearchSource { getConfig(UI_SETTINGS.SORT_OPTIONS) ); return addToBody(key, sort); + case 'aggs': + if ((val as any) instanceof AggConfigs) { + return addToBody('aggs', val.toDsl()); + } else { + return addToBody('aggs', val); + } default: return addToBody(key, val); } @@ -496,18 +635,15 @@ export class SearchSource { if (!sourceFilters || sourceFilters.excludes?.length === 0 || bodyFields.length === 0) { return bodyFields; } - const metaFields = this.dependencies.getConfig(UI_SETTINGS.META_FIELDS); const sourceFiltersValues = sourceFilters.excludes; const wildcardField = bodyFields.find( (el: SearchFieldValue) => el === '*' || (el as Record).field === '*' ); - const filterSourceFields = (fieldName: string) => { - return ( - fieldName && - !sourceFiltersValues.some((sourceFilter) => fieldName.match(sourceFilter)) && - !metaFields.includes(fieldName) - ); - }; + const filter = fieldWildcardFilter( + sourceFiltersValues, + this.dependencies.getConfig(UI_SETTINGS.META_FIELDS) + ); + const filterSourceFields = (fieldName: string) => fieldName && filter(fieldName); if (!wildcardField) { // we already have an explicit list of fields, so we just remove source filters from that list return bodyFields.filter((fld: SearchFieldValue) => diff --git a/src/plugins/data/common/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts index a178b38693d92..507fda0dc9e47 100644 --- a/src/plugins/data/common/search/search_source/types.ts +++ b/src/plugins/data/common/search/search_source/types.ts @@ -7,6 +7,7 @@ */ import { NameList } from 'elasticsearch'; +import { IAggConfigs } from 'src/plugins/data/public'; import { Query } from '../..'; import { Filter } from '../../es_query'; import { IndexPattern } from '../../index_patterns'; @@ -41,12 +42,20 @@ export enum SortDirection { desc = 'desc', } +export interface SortDirectionFormat { + order: SortDirection; + format?: string; +} + export interface SortDirectionNumeric { order: SortDirection; numeric_type?: 'double' | 'long' | 'date' | 'date_nanos'; } -export type EsQuerySortValue = Record; +export type EsQuerySortValue = Record< + string, + SortDirection | SortDirectionNumeric | SortDirectionFormat +>; interface SearchField { [key: string]: SearchFieldValue; @@ -78,7 +87,7 @@ export interface SearchSourceFields { /** * {@link AggConfigs} */ - aggs?: any; + aggs?: object | IAggConfigs | (() => object); from?: number; size?: number; source?: NameList; diff --git a/src/plugins/data/common/search/tabify/index.ts b/src/plugins/data/common/search/tabify/index.ts index 168d4cf9d4c37..74fbc7ba4cfa4 100644 --- a/src/plugins/data/common/search/tabify/index.ts +++ b/src/plugins/data/common/search/tabify/index.ts @@ -6,27 +6,6 @@ * Side Public License, v 1. */ -import { SearchResponse } from 'elasticsearch'; -import { SearchSource } from '../search_source'; -import { tabifyAggResponse } from './tabify'; -import { tabifyDocs, TabifyDocsOptions } from './tabify_docs'; -import { TabbedResponseWriterOptions } from './types'; - -export const tabify = ( - searchSource: SearchSource, - esResponse: SearchResponse, - opts: Partial | TabifyDocsOptions -) => { - return !esResponse.aggregations - ? tabifyDocs(esResponse, searchSource.getField('index'), opts as TabifyDocsOptions) - : tabifyAggResponse( - searchSource.getField('aggs'), - esResponse, - opts as Partial - ); -}; - -export { tabifyDocs }; - +export { tabifyDocs } from './tabify_docs'; export { tabifyAggResponse } from './tabify'; export { tabifyGetColumns } from './get_columns'; diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts index 9f096886491ad..4a8972d4384c2 100644 --- a/src/plugins/data/common/search/tabify/tabify.ts +++ b/src/plugins/data/common/search/tabify/tabify.ts @@ -139,7 +139,7 @@ export function tabifyAggResponse( const write = new TabbedAggResponseWriter(aggConfigs, respOpts || {}); const topLevelBucket: AggResponseBucket = { ...esResponse.aggregations, - doc_count: esResponse.hits.total, + doc_count: esResponse.hits?.total, }; collectBucket(aggConfigs, write, topLevelBucket, '', 1); diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index 37de8dc49d3c6..e3ec499a0020d 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -9,7 +9,7 @@ import { Observable } from 'rxjs'; import { IEsSearchRequest, IEsSearchResponse } from './es_search'; import { IndexPattern } from '..'; -import type { RequestResponder } from '../../../inspector/common'; +import type { RequestAdapter } from '../../../inspector/common'; export type ISearchGeneric = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, @@ -81,6 +81,13 @@ export interface IKibanaSearchRequest { params?: Params; } +export interface IInspectorInfo { + adapter?: RequestAdapter; + title: string; + id?: string; + description?: string; +} + export interface ISearchOptions { /** * An `AbortSignal` that allows the caller of `search` to abort a search request. @@ -117,10 +124,12 @@ export interface ISearchOptions { /** * Index pattern reference is used for better error messages */ - indexPattern?: IndexPattern; - requestResponder?: RequestResponder; + /** + * Inspector integration options + */ + inspector?: IInspectorInfo; } /** diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index d99d754a3364d..dc138b7347d04 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -46,6 +46,7 @@ import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_ import { History } from 'history'; import { Href } from 'history'; import { HttpSetup } from 'kibana/public'; +import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public'; import { IconType } from '@elastic/eui'; import { IncomingHttpHeaders } from 'http'; import { InjectedIntl } from '@kbn/i18n/react'; @@ -254,6 +255,8 @@ export class AggConfigs { getResponseAggById(id: string): AggConfig | undefined; getResponseAggs(): AggConfig[]; // (undocumented) + hierarchical?: boolean; + // (undocumented) indexPattern: IndexPattern; jsonDataEquals(aggConfigs: AggConfig[]): boolean; // (undocumented) @@ -267,7 +270,7 @@ export class AggConfigs { // (undocumented) timeRange?: TimeRange; // (undocumented) - toDsl(hierarchical?: boolean): Record; + toDsl(): Record; } // @internal (undocumented) @@ -829,10 +832,11 @@ export interface EsQueryConfig { } // Warning: (ae-forgotten-export) The symbol "SortDirectionNumeric" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "SortDirectionFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "EsQuerySortValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type EsQuerySortValue = Record; +export type EsQuerySortValue = Record; // Warning: (ae-forgotten-export) The symbol "ExpressionTypeDefinition" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "name" needs to be exported by the entry point index.d.ts @@ -1672,13 +1676,11 @@ export type ISearchGeneric = ; + // (undocumented) protected getTimeoutMode(): TimeoutErrorMode; // Warning: (ae-forgotten-export) The symbol "KibanaServerError" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts @@ -2428,9 +2432,9 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; - fetch$(options?: ISearchOptions): import("rxjs").Observable>; + fetch$(options?: ISearchOptions): Observable>; // @deprecated - fetch(options?: ISearchOptions): Promise>; + fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; getFields(): SearchSourceFields; getId(): string; @@ -2460,7 +2464,7 @@ export class SearchSource { // @public export interface SearchSourceFields { // (undocumented) - aggs?: any; + aggs?: object | IAggConfigs_2 | (() => object); // Warning: (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts fields?: SearchFieldValue[]; // @deprecated diff --git a/src/plugins/data/public/search/expressions/esaggs.test.ts b/src/plugins/data/public/search/expressions/esaggs.test.ts index d7a6446781c43..e75bd7be219de 100644 --- a/src/plugins/data/public/search/expressions/esaggs.test.ts +++ b/src/plugins/data/public/search/expressions/esaggs.test.ts @@ -100,17 +100,20 @@ describe('esaggs expression function - public', () => { expect(handleEsaggsRequest).toHaveBeenCalledWith({ abortSignal: mockHandlers.abortSignal, - aggs: { foo: 'bar' }, + aggs: { + foo: 'bar', + hierarchical: true, + }, filters: undefined, indexPattern: {}, inspectorAdapters: mockHandlers.inspectorAdapters, - metricsAtAllLevels: args.metricsAtAllLevels, partialRows: args.partialRows, query: undefined, searchSessionId: 'abc123', searchSourceService: startDependencies.searchSource, timeFields: args.timeFields, timeRange: undefined, + getNow: undefined, }); }); diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts index 45d24af3a6ebb..1e3d56c71e423 100644 --- a/src/plugins/data/public/search/expressions/esaggs.ts +++ b/src/plugins/data/public/search/expressions/esaggs.ts @@ -8,7 +8,6 @@ import { get } from 'lodash'; import { StartServicesAccessor } from 'src/core/public'; -import { Adapters } from 'src/plugins/inspector/common'; import { EsaggsExpressionFunctionDefinition, EsaggsStartDependencies, @@ -44,14 +43,14 @@ export function getFunctionDefinition({ indexPattern, args.aggs!.map((agg) => agg.value) ); + aggConfigs.hierarchical = args.metricsAtAllLevels; return await handleEsaggsRequest({ - abortSignal: (abortSignal as unknown) as AbortSignal, + abortSignal, aggs: aggConfigs, filters: get(input, 'filters', undefined), indexPattern, - inspectorAdapters: inspectorAdapters as Adapters, - metricsAtAllLevels: args.metricsAtAllLevels, + inspectorAdapters, partialRows: args.partialRows, query: get(input, 'query', undefined) as any, searchSessionId: getSearchSessionId(), diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 3df2313f83798..e3fb31c9179fd 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -113,20 +113,14 @@ export class SearchInterceptor { } } - /** - * @internal - * @throws `AbortError` | `ErrorLike` - */ - protected runSearch( - request: IKibanaSearchRequest, - options?: ISearchOptions - ): Promise { - const { abortSignal, sessionId, ...requestOptions } = options || {}; + protected getSerializableOptions(options?: ISearchOptions) { + const { sessionId, ...requestOptions } = options || {}; + + const serializableOptions: ISearchOptionsSerializable = {}; const combined = { ...requestOptions, ...this.deps.session.getSearchOptions(sessionId), }; - const serializableOptions: ISearchOptionsSerializable = {}; if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId; if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore; @@ -135,10 +129,22 @@ export class SearchInterceptor { if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy; if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored; + return serializableOptions; + } + + /** + * @internal + * @throws `AbortError` | `ErrorLike` + */ + protected runSearch( + request: IKibanaSearchRequest, + options?: ISearchOptions + ): Promise { + const { abortSignal } = options || {}; return this.batchedFetch( { request, - options: serializableOptions, + options: this.getSerializableOptions(options), }, abortSignal ); diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 381410574ecda..71f51b4bc8d83 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -73,7 +73,7 @@ export interface SearchSessionIndicatorUiConfig { } /** - * Responsible for tracking a current search session. Supports only a single session at a time. + * Responsible for tracking a current search session. Supports a single session at a time. */ export class SessionService { public readonly state$: Observable; 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 4436efb1f3508..9896a6dbdc7b7 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 @@ -1112,19 +1112,19 @@ exports[`Inspector Data View component should render single table without select - - - - Click to sort in ascending order - - - + + + + Click to sort in ascending order + + + @@ -2666,19 +2666,19 @@ exports[`Inspector Data View component should support multiple datatables 1`] = - - - - Click to sort in ascending order - - - + + + + Click to sort in ascending order + + + diff --git a/src/plugins/data/server/search/expressions/esaggs.test.ts b/src/plugins/data/server/search/expressions/esaggs.test.ts index 124a171de6378..15287e9d8cf5b 100644 --- a/src/plugins/data/server/search/expressions/esaggs.test.ts +++ b/src/plugins/data/server/search/expressions/esaggs.test.ts @@ -108,11 +108,13 @@ describe('esaggs expression function - server', () => { expect(handleEsaggsRequest).toHaveBeenCalledWith({ abortSignal: mockHandlers.abortSignal, - aggs: { foo: 'bar' }, + aggs: { + foo: 'bar', + hierarchical: args.metricsAtAllLevels, + }, filters: undefined, indexPattern: {}, inspectorAdapters: mockHandlers.inspectorAdapters, - metricsAtAllLevels: args.metricsAtAllLevels, partialRows: args.partialRows, query: undefined, searchSessionId: 'abc123', diff --git a/src/plugins/data/server/search/expressions/esaggs.ts b/src/plugins/data/server/search/expressions/esaggs.ts index 61fd320d89b95..bb22a491b157e 100644 --- a/src/plugins/data/server/search/expressions/esaggs.ts +++ b/src/plugins/data/server/search/expressions/esaggs.ts @@ -9,7 +9,6 @@ import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, StartServicesAccessor } from 'src/core/server'; -import { Adapters } from 'src/plugins/inspector/common'; import { EsaggsExpressionFunctionDefinition, EsaggsStartDependencies, @@ -61,13 +60,14 @@ export function getFunctionDefinition({ args.aggs!.map((agg) => agg.value) ); + aggConfigs.hierarchical = args.metricsAtAllLevels; + return await handleEsaggsRequest({ - abortSignal: (abortSignal as unknown) as AbortSignal, + abortSignal, aggs: aggConfigs, filters: get(input, 'filters', undefined), indexPattern, - inspectorAdapters: inspectorAdapters as Adapters, - metricsAtAllLevels: args.metricsAtAllLevels, + inspectorAdapters, partialRows: args.partialRows, query: get(input, 'query', undefined) as any, searchSessionId: getSearchSessionId(), diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 622356c4441ac..3316e8102e50a 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -26,12 +26,14 @@ import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; import { ErrorToastOptions } from 'src/core/public/notifications'; import { estypes } from '@elastic/elasticsearch'; +import { EventEmitter } from 'events'; import { ExecutionContext } from 'src/plugins/expressions/common'; import { ExpressionAstExpression } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils'; +import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public'; import { ISavedObjectsRepository } from 'src/core/server'; import { IScopedClusterClient } from 'src/core/server'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; @@ -999,13 +1001,11 @@ export interface IScopedSearchClient extends ISearchClient { export interface ISearchOptions { abortSignal?: AbortSignal; indexPattern?: IndexPattern; + // Warning: (ae-forgotten-export) The symbol "IInspectorInfo" needs to be exported by the entry point index.d.ts + inspector?: IInspectorInfo; isRestore?: boolean; isStored?: boolean; legacyHitsTotal?: boolean; - // Warning: (ae-forgotten-export) The symbol "RequestResponder" needs to be exported by the entry point index.d.ts - // - // (undocumented) - requestResponder?: RequestResponder; sessionId?: string; strategy?: string; } diff --git a/src/plugins/dev_tools/public/dev_tool.ts b/src/plugins/dev_tools/public/dev_tool.ts index 197e93f20a539..8adfd4c76482d 100644 --- a/src/plugins/dev_tools/public/dev_tool.ts +++ b/src/plugins/dev_tools/public/dev_tool.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { ReactNode } from 'react'; import { AppMount } from 'src/core/public'; /** @@ -26,8 +27,9 @@ export class DevToolApp { /** * The human readable name of the dev tool. Should be internationalized. * This will be used as a label in the tab above the actual tool. + * May also be a ReactNode. */ - public readonly title: string; + public readonly title: ReactNode; public readonly mount: AppMount; /** @@ -55,7 +57,7 @@ export class DevToolApp { constructor( id: string, - title: string, + title: ReactNode, mount: AppMount, enableRouting: boolean, order: number, diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 6cf3c57d19ac8..e9f5d206de918 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -7,7 +7,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import { Plugin, CoreSetup, AppMountParameters } from 'src/core/public'; +import { Plugin, CoreSetup, AppMountParameters, AppSearchDeepLink } from 'src/core/public'; import { AppUpdater } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { sortBy } from 'lodash'; @@ -84,6 +84,20 @@ export class DevToolsPlugin implements Plugin { public start() { if (this.getSortedDevTools().length === 0) { this.appStateUpdater.next(() => ({ navLinkStatus: AppNavLinkStatus.hidden })); + } else { + this.appStateUpdater.next(() => { + const deepLinks: AppSearchDeepLink[] = [...this.devTools.values()] + .filter( + // Some tools do not use a string title, so we filter those out + (tool) => !tool.enableRouting && !tool.isDisabled() && typeof tool.title === 'string' + ) + .map((tool) => ({ + id: tool.id, + title: tool.title as string, + path: `#/${tool.id}`, + })); + return { meta: { searchDeepLinks: deepLinks } }; + }); } } diff --git a/src/plugins/discover/public/application/angular/context/api/_stubs.js b/src/plugins/discover/public/application/angular/context/api/_stubs.js index 3baa7424e35d7..6930e96a0d411 100644 --- a/src/plugins/discover/public/application/angular/context/api/_stubs.js +++ b/src/plugins/discover/public/application/angular/context/api/_stubs.js @@ -66,7 +66,7 @@ export function createContextSearchSourceStub(hits, timeField = '@timestamp') { const lastQuery = searchSourceStub.setField.withArgs('query').lastCall.args[1]; const timeRange = lastQuery.query.bool.must.constant_score.filter.range[timeField]; const lastSort = searchSourceStub.setField.withArgs('sort').lastCall.args[1]; - const sortDirection = lastSort[0][timeField]; + const sortDirection = lastSort[0][timeField].order; const sortFunction = sortDirection === 'asc' ? (first, second) => first[timeField] - second[timeField] diff --git a/src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js b/src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js index ab47111f70e92..9f5e62da398d2 100644 --- a/src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js +++ b/src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js @@ -211,7 +211,10 @@ describe('context app', function () { [] ).then(() => { expect( - mockSearchSource.setField.calledWith('sort', [{ '@timestamp': 'asc' }, { _doc: 'asc' }]) + mockSearchSource.setField.calledWith('sort', [ + { '@timestamp': { order: 'asc', format: 'strict_date_optional_time' } }, + { _doc: 'asc' }, + ]) ).toBe(true); }); }); diff --git a/src/plugins/discover/public/application/angular/context/api/context.successors.test.js b/src/plugins/discover/public/application/angular/context/api/context.successors.test.js index d4f3754f3d0a5..4936c937aa2fa 100644 --- a/src/plugins/discover/public/application/angular/context/api/context.successors.test.js +++ b/src/plugins/discover/public/application/angular/context/api/context.successors.test.js @@ -215,7 +215,10 @@ describe('context app', function () { [] ).then(() => { expect( - mockSearchSource.setField.calledWith('sort', [{ '@timestamp': 'desc' }, { _doc: 'desc' }]) + mockSearchSource.setField.calledWith('sort', [ + { '@timestamp': { order: 'desc', format: 'strict_date_optional_time' } }, + { _doc: 'desc' }, + ]) ).toBe(true); }); }); diff --git a/src/plugins/discover/public/application/angular/context/api/context.ts b/src/plugins/discover/public/application/angular/context/api/context.ts index 43f6e83d286b3..820e37d754ef2 100644 --- a/src/plugins/discover/public/application/angular/context/api/context.ts +++ b/src/plugins/discover/public/application/angular/context/api/context.ts @@ -87,7 +87,7 @@ function fetchContextProvider(indexPatterns: IndexPatternsContract, useNewFields useNewFieldsApi ); - const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply); + const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply, nanos); const hits = await fetchHitsInInterval( searchSource, diff --git a/src/plugins/discover/public/application/angular/context/api/utils/get_es_query_sort.ts b/src/plugins/discover/public/application/angular/context/api/utils/get_es_query_sort.ts index a560315dc04a4..2144d2f1cd7fd 100644 --- a/src/plugins/discover/public/application/angular/context/api/utils/get_es_query_sort.ts +++ b/src/plugins/discover/public/application/angular/context/api/utils/get_es_query_sort.ts @@ -14,11 +14,21 @@ import { EsQuerySortValue, SortDirection } from '../../../../../kibana_services' * @param timeField * @param tieBreakerField * @param sortDir + * @param nanos */ export function getEsQuerySort( timeField: string, tieBreakerField: string, - sortDir: SortDirection + sortDir: SortDirection, + nanos?: string ): [EsQuerySortValue, EsQuerySortValue] { - return [{ [timeField]: sortDir }, { [tieBreakerField]: sortDir }]; + return [ + { + [timeField]: { + order: sortDir, + format: nanos ? 'strict_date_optional_time_nanos' : 'strict_date_optional_time', + }, + }, + { [tieBreakerField]: sortDir }, + ]; } diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 35a89eb45f35e..4099d5e8ef7e2 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -415,11 +415,20 @@ function discoverController($route, $scope) { $scope.fetchStatus = fetchStatuses.LOADING; $scope.resultState = getResultState($scope.fetchStatus, $scope.rows); + inspectorAdapters.requests.reset(); return $scope.volatileSearchSource .fetch$({ abortSignal: abortController.signal, sessionId: searchSessionId, - requestResponder: getRequestResponder({ searchSessionId }), + inspector: { + adapter: inspectorAdapters.requests, + title: i18n.translate('discover.inspectorRequestDataTitle', { + defaultMessage: 'data', + }), + description: i18n.translate('discover.inspectorRequestDescription', { + defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', + }), + }, }) .toPromise() .then(onResults) @@ -465,17 +474,6 @@ function discoverController($route, $scope) { await refetch$.next(); }; - function getRequestResponder({ searchSessionId = null } = { searchSessionId: null }) { - inspectorAdapters.requests.reset(); - const title = i18n.translate('discover.inspectorRequestDataTitle', { - defaultMessage: 'data', - }); - const description = i18n.translate('discover.inspectorRequestDescription', { - defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', - }); - return inspectorAdapters.requests.start(title, { description, searchSessionId }); - } - $scope.resetQuery = function () { history.push( $route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/' diff --git a/src/plugins/discover/public/application/angular/discover_state.test.ts b/src/plugins/discover/public/application/angular/discover_state.test.ts index e7322a8588631..ddb4e874ccc64 100644 --- a/src/plugins/discover/public/application/angular/discover_state.test.ts +++ b/src/plugins/discover/public/application/angular/discover_state.test.ts @@ -79,6 +79,48 @@ describe('Test discover state', () => { expect(state.getPreviousAppState()).toEqual(stateA); }); }); +describe('Test discover initial state sort handling', () => { + test('Non-empty sort in URL should not fallback to state defaults', async () => { + history = createBrowserHistory(); + history.push('/#?_a=(sort:!(!(order_date,desc)))'); + + state = getState({ + getStateDefaults: () => ({ sort: [['fallback', 'desc']] }), + history, + uiSettings: uiSettingsMock, + }); + await state.replaceUrlAppState({}); + await state.startSync(); + expect(state.appStateContainer.getState().sort).toMatchInlineSnapshot(` + Array [ + Array [ + "order_date", + "desc", + ], + ] + `); + }); + test('Empty sort in URL should allow fallback state defaults', async () => { + history = createBrowserHistory(); + history.push('/#?_a=(sort:!())'); + + state = getState({ + getStateDefaults: () => ({ sort: [['fallback', 'desc']] }), + history, + uiSettings: uiSettingsMock, + }); + await state.replaceUrlAppState({}); + await state.startSync(); + expect(state.appStateContainer.getState().sort).toMatchInlineSnapshot(` + Array [ + Array [ + "fallback", + "desc", + ], + ] + `); + }); +}); describe('Test discover state with legacy migration', () => { test('migration of legacy query ', async () => { diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index 9ebeff69d7542..f71e3ac651f53 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -170,6 +170,12 @@ export function getState({ appStateFromUrl.query = migrateLegacyQuery(appStateFromUrl.query); } + if (appStateFromUrl?.sort && !appStateFromUrl.sort.length) { + // If there's an empty array given in the URL, the sort prop should be removed + // This allows the sort prop to be overwritten with the default sorting + delete appStateFromUrl.sort; + } + let initialAppState = handleSourceColumnState( { ...defaultAppState, @@ -177,6 +183,7 @@ export function getState({ }, uiSettings ); + // todo filter source depending on fields fetching flag (if no columns remain and source fetching is enabled, use default columns) let previousAppState: AppState; const appStateContainer = createStateContainer(initialAppState); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx index f31399793c0da..3894127891041 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx @@ -178,15 +178,29 @@ export function DiscoverGridFlyout({ indexPattern={indexPattern} filter={(mapping, value, mode) => { onFilter(mapping, value, mode); - onClose(); + services.toastNotifications.addSuccess( + i18n.translate('discover.grid.flyout.toastFilterAdded', { + defaultMessage: `Filter was added`, + }) + ); }} onRemoveColumn={(columnName: string) => { onRemoveColumn(columnName); - onClose(); + services.toastNotifications.addSuccess( + i18n.translate('discover.grid.flyout.toastColumnRemoved', { + defaultMessage: `Column '{columnName}' was removed`, + values: { columnName }, + }) + ); }} onAddColumn={(columnName: string) => { onAddColumn(columnName); - onClose(); + services.toastNotifications.addSuccess( + i18n.translate('discover.grid.flyout.toastColumnAdded', { + defaultMessage: `Column '{columnName}' was added`, + values: { columnName }, + }) + ); }} /> diff --git a/src/plugins/discover/public/application/components/discover_topnav.test.tsx b/src/plugins/discover/public/application/components/discover_topnav.test.tsx index 891dc63c92c7c..d30e5bda1abe7 100644 --- a/src/plugins/discover/public/application/components/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/components/discover_topnav.test.tsx @@ -33,6 +33,9 @@ function getProps(): DiscoverTopNavProps { discover: { save: true, }, + advancedSettings: { + save: true, + }, }, uiSettings: mockUiSettings, } as unknown) as DiscoverServices; diff --git a/src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap b/src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap new file mode 100644 index 0000000000000..44b8cbb8b839a --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap @@ -0,0 +1,661 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Discover IndexPattern Management renders correctly 1`] = ` + +`; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_management.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_management.test.tsx new file mode 100644 index 0000000000000..88644dc213fd6 --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_management.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getStubIndexPattern } from '../../../../../data/public/index_patterns/index_pattern.stub'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { DiscoverServices } from '../../../build_services'; +// @ts-ignore +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; +import { mountWithIntl } from '@kbn/test/jest'; +import React from 'react'; +import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; + +const mockServices = ({ + history: () => ({ + location: { + search: '', + }, + }), + capabilities: { + visualize: { + show: true, + }, + discover: { + save: false, + }, + }, + uiSettings: { + get: (key: string) => { + if (key === 'fields:popularLimit') { + return 5; + } + }, + }, + indexPatternFieldEditor: { + openEditor: jest.fn(), + userPermissions: { + editIndexPattern: jest.fn(), + }, + }, +} as unknown) as DiscoverServices; + +jest.mock('../../../kibana_services', () => ({ + getServices: () => mockServices, +})); + +describe('Discover IndexPattern Management', () => { + const indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() + ); + + const editField = jest.fn(); + + test('renders correctly', () => { + const component = mountWithIntl( + + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_management.tsx b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_management.tsx new file mode 100644 index 0000000000000..38681d75a4e1d --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_management.tsx @@ -0,0 +1,107 @@ +/* + * 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, { useState } from 'react'; +import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DiscoverServices } from '../../../build_services'; +import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns'; + +export interface DiscoverIndexPatternManagementProps { + /** + * Currently selected index pattern + */ + selectedIndexPattern?: IndexPattern; + /** + * Discover plugin services; + */ + services: DiscoverServices; + /** + * Read from the Fields API + */ + useNewFieldsApi?: boolean; + /** + * Callback to execute on edit field action + * @param fieldName + */ + editField: (fieldName?: string) => void; +} + +export function DiscoverIndexPatternManagement(props: DiscoverIndexPatternManagementProps) { + const { indexPatternFieldEditor, core } = props.services; + const { useNewFieldsApi, selectedIndexPattern, editField } = props; + const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern(); + const canEditIndexPatternField = !!indexPatternFieldEditPermission && useNewFieldsApi; + const [isAddIndexPatternFieldPopoverOpen, setIsAddIndexPatternFieldPopoverOpen] = useState(false); + + if (!useNewFieldsApi || !selectedIndexPattern || !canEditIndexPatternField) { + return null; + } + + const addField = () => { + editField(undefined); + }; + + return ( + { + setIsAddIndexPatternFieldPopoverOpen(false); + }} + ownFocus + data-test-subj="discover-addRuntimeField-popover" + button={ + { + setIsAddIndexPatternFieldPopoverOpen(!isAddIndexPatternFieldPopoverOpen); + }} + /> + } + > + { + setIsAddIndexPatternFieldPopoverOpen(false); + addField(); + }} + > + {i18n.translate('discover.fieldChooser.indexPatterns.addFieldButton', { + defaultMessage: 'Add field to index pattern', + })} + , + { + setIsAddIndexPatternFieldPopoverOpen(false); + core.application.navigateToApp('management', { + path: `/kibana/indexPatterns/patterns/${props.selectedIndexPattern?.id}`, + }); + }} + > + {i18n.translate('discover.fieldChooser.indexPatterns.manageFieldButton', { + defaultMessage: 'Manage index pattern fields', + })} + , + ]} + /> + + ); +} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx index 0b3f55b5630cc..01541344be7e1 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx @@ -109,6 +109,7 @@ function getCompProps(): DiscoverSidebarProps { setFieldFilter: jest.fn(), setAppState: jest.fn(), onEditRuntimeField: jest.fn(), + editField: jest.fn(), }; } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index d97f98b9e054f..aaaf72f770630 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -19,10 +19,6 @@ import { EuiSpacer, EuiNotificationBadge, EuiPageSideBar, - EuiContextMenuPanel, - EuiContextMenuItem, - EuiPopover, - EuiButtonIcon, useResizeObserver, } from '@elastic/eui'; @@ -38,6 +34,7 @@ import { getDetails } from './lib/get_details'; import { FieldFilterState, getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter'; import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive'; +import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; /** * Default number of available fields displayed and added on scroll @@ -64,6 +61,8 @@ export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps { * @param ref reference to the field editor component */ setFieldEditorRef?: (ref: () => void | undefined) => void; + + editField: (fieldName?: string) => void; } export function DiscoverSidebar({ @@ -90,10 +89,10 @@ export function DiscoverSidebar({ onEditRuntimeField, setFieldEditorRef, closeFlyout, + editField, }: DiscoverSidebarProps) { const [fields, setFields] = useState(null); - const [isAddIndexPatternFieldPopoverOpen, setIsAddIndexPatternFieldPopoverOpen] = useState(false); - const { indexPatternFieldEditor, core } = services; + const { indexPatternFieldEditor } = services; const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern(); const canEditIndexPatternField = !!indexPatternFieldEditPermission && useNewFieldsApi; const [scrollContainer, setScrollContainer] = useState(null); @@ -273,31 +272,6 @@ export function DiscoverSidebar({ return null; } - const editField = (fieldName?: string) => { - if (!canEditIndexPatternField) { - return; - } - const ref = indexPatternFieldEditor.openEditor({ - ctx: { - indexPattern: selectedIndexPattern, - }, - fieldName, - onSave: async () => { - onEditRuntimeField(); - }, - }); - if (setFieldEditorRef) { - setFieldEditorRef(ref); - } - if (closeFlyout) { - closeFlyout(); - } - }; - - const addField = () => { - editField(undefined); - }; - if (useFlyout) { return (
- o.attributes.title)} - indexPatterns={indexPatterns} - state={state} - setAppState={setAppState} - /> + + + o.attributes.title)} + indexPatterns={indexPatterns} + state={state} + setAppState={setAppState} + /> + + + + +
); } - const indexPatternActions = ( - { - setIsAddIndexPatternFieldPopoverOpen(false); - }} - ownFocus - data-test-subj="discover-addRuntimeField-popover" - button={ - { - setIsAddIndexPatternFieldPopoverOpen(!isAddIndexPatternFieldPopoverOpen); - }} - /> - } - > - { - setIsAddIndexPatternFieldPopoverOpen(false); - addField(); - }} - > - {i18n.translate('discover.fieldChooser.indexPatterns.addFieldButton', { - defaultMessage: 'Add field to index pattern', - })} - , - { - setIsAddIndexPatternFieldPopoverOpen(false); - core.application.navigateToApp('management', { - path: `/kibana/indexPatterns/patterns/${selectedIndexPattern.id}`, - }); - }} - > - {i18n.translate('discover.fieldChooser.indexPatterns.manageFieldButton', { - defaultMessage: 'Manage index pattern fields', - })} - , - ]} - /> - - ); - return ( - {useNewFieldsApi && {indexPatternActions}} + + + diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.tsx index 6a16399f0e2e1..6b8918e2d9965 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.tsx @@ -24,6 +24,8 @@ import { EuiIcon, EuiLink, EuiPortal, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { DiscoverIndexPattern } from './discover_index_pattern'; import { IndexPatternAttributes, IndexPatternsContract } from '../../../../../data/common'; @@ -34,6 +36,7 @@ import { DiscoverSidebar } from './discover_sidebar'; import { DiscoverServices } from '../../../build_services'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; import { AppState } from '../../angular/discover_state'; +import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; export interface DiscoverSidebarResponsiveProps { /** @@ -121,7 +124,9 @@ export interface DiscoverSidebarResponsiveProps { */ showUnmappedFields: boolean; }; - + /** + * callback to execute on edit runtime field + */ onEditRuntimeField: () => void; } @@ -160,6 +165,31 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) setIsFlyoutVisible(false); }; + const { indexPatternFieldEditor } = props.services; + const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern(); + const canEditIndexPatternField = !!indexPatternFieldEditPermission && props.useNewFieldsApi; + + const editField = (fieldName?: string) => { + if (!canEditIndexPatternField || !props.selectedIndexPattern) { + return; + } + const ref = indexPatternFieldEditor.openEditor({ + ctx: { + indexPattern: props.selectedIndexPattern, + }, + fieldName, + onSave: async () => { + props.onEditRuntimeField(); + }, + }); + if (setFieldEditorRef) { + setFieldEditorRef(ref); + } + if (closeFlyout) { + closeFlyout(); + } + }; + return ( <> {props.isClosed ? null : ( @@ -168,7 +198,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {...props} fieldFilter={fieldFilter} setFieldFilter={setFieldFilter} - setFieldEditorRef={setFieldEditorRef} + editField={editField} /> )} @@ -182,15 +212,28 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) } )} > - o.attributes.title)} - indexPatterns={props.indexPatterns} - state={props.state} - setAppState={props.setAppState} - /> + + + o.attributes.title)} + indexPatterns={props.indexPatterns} + state={props.state} + setAppState={props.setAppState} + /> + + + + + + diff --git a/src/plugins/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index 8997c1d13a474..7539f29c1ec9d 100644 --- a/src/plugins/discover/public/application/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -155,7 +155,7 @@ describe('DocViewTable at Discover', () => { const elementExist = check[element]; if (typeof elementExist === 'boolean') { - const btn = findTestSubject(rowComponent, element); + const btn = findTestSubject(rowComponent, element, '^='); it(`renders ${element} for '${check._property}' correctly`, () => { const disabled = btn.length ? btn.props().disabled : true; diff --git a/src/plugins/discover/public/application/components/table/table_row.tsx b/src/plugins/discover/public/application/components/table/table_row.tsx index 5c6ae49770bc7..e8977fda8576a 100644 --- a/src/plugins/discover/public/application/components/table/table_row.tsx +++ b/src/plugins/discover/public/application/components/table/table_row.tsx @@ -65,7 +65,11 @@ export function DocViewTableRow({ onClick={() => onFilter(fieldMapping, valueRaw, '-')} /> {typeof onToggleColumn === 'function' && ( - + )} void; + fieldname: string; } -export function DocViewTableRowBtnToggleColumn({ onClick, active, disabled = false }: Props) { +export function DocViewTableRowBtnToggleColumn({ + onClick, + active, + disabled = false, + fieldname = '', +}: Props) { if (disabled) { return ( diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts index f6e9e70b337ba..2c50ce61c8afb 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts @@ -18,6 +18,9 @@ const services = ({ discover: { save: true, }, + advancedSettings: { + save: true, + }, }, } as unknown) as DiscoverServices; @@ -36,6 +39,13 @@ test('getTopNavLinks result', () => { }); expect(topNavLinks).toMatchInlineSnapshot(` Array [ + Object { + "description": "Options", + "id": "options", + "label": "Options", + "run": [Function], + "testId": "discoverOptionsButton", + }, Object { "description": "New Search", "id": "new", diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts index 635684177e1e3..9a12cb51eac0c 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts @@ -15,6 +15,7 @@ import { SavedSearch } from '../../../saved_searches'; import { onSaveSearch } from './on_save_search'; import { GetStateReturn } from '../../angular/discover_state'; import { IndexPattern, ISearchSource } from '../../../kibana_services'; +import { openOptionsPopover } from './open_options_popover'; /** * Helper function to build the top nav links @@ -38,6 +39,22 @@ export const getTopNavLinks = ({ onOpenInspector: () => void; searchSource: ISearchSource; }) => { + const options = { + id: 'options', + label: i18n.translate('discover.localMenu.localMenu.optionsTitle', { + defaultMessage: 'Options', + }), + description: i18n.translate('discover.localMenu.optionsDescription', { + defaultMessage: 'Options', + }), + run: (anchorElement: HTMLElement) => + openOptionsPopover({ + I18nContext: services.core.i18n.Context, + anchorElement, + }), + testId: 'discoverOptionsButton', + }; + const newSearch = { id: 'new', label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { @@ -128,6 +145,7 @@ export const getTopNavLinks = ({ }; return [ + ...(services.capabilities.advancedSettings.save ? [options] : []), newSearch, ...(services.capabilities.discover.save ? [saveSearch] : []), openSearch, diff --git a/src/plugins/discover/public/application/components/top_nav/open_options_popover.scss b/src/plugins/discover/public/application/components/top_nav/open_options_popover.scss new file mode 100644 index 0000000000000..f68b2bfe74a9d --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/open_options_popover.scss @@ -0,0 +1,5 @@ +$dscOptionsPopoverWidth: $euiSizeL * 12; + +.dscOptionsPopover { + width: $dscOptionsPopoverWidth; +} \ No newline at end of file diff --git a/src/plugins/discover/public/application/components/top_nav/open_options_popover.test.tsx b/src/plugins/discover/public/application/components/top_nav/open_options_popover.test.tsx new file mode 100644 index 0000000000000..406d2eb8eac4b --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/open_options_popover.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { findTestSubject } from '@elastic/eui/lib/test'; +import { getServices } from '../../../kibana_services'; + +jest.mock('../../../kibana_services', () => { + const mockUiSettings = new Map(); + return { + getServices: () => ({ + core: { + uiSettings: { + get: (key: string) => { + return mockUiSettings.get(key); + }, + set: (key: string, value: boolean) => { + mockUiSettings.set(key, value); + }, + }, + }, + addBasePath: (path: string) => path, + }), + }; +}); + +import { OptionsPopover } from './open_options_popover'; + +test('should display the correct text if datagrid is selected', () => { + const element = document.createElement('div'); + const component = mountWithIntl(); + expect(findTestSubject(component, 'docTableMode').text()).toBe('Data grid'); +}); + +test('should display the correct text if legacy table is selected', () => { + const { + core: { uiSettings }, + } = getServices(); + uiSettings.set('doc_table:legacy', true); + const element = document.createElement('div'); + const component = mountWithIntl(); + expect(findTestSubject(component, 'docTableMode').text()).toBe('Legacy table'); +}); diff --git a/src/plugins/discover/public/application/components/top_nav/open_options_popover.tsx b/src/plugins/discover/public/application/components/top_nav/open_options_popover.tsx new file mode 100644 index 0000000000000..6eb7fccd3aaa8 --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/open_options_popover.tsx @@ -0,0 +1,98 @@ +/* + * 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 ReactDOM from 'react-dom'; +import { I18nStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiButton, EuiText, EuiWrappingPopover, EuiCode } from '@elastic/eui'; +import { getServices } from '../../../kibana_services'; +import './open_options_popover.scss'; + +let isOpen = false; + +interface OptionsPopoverProps { + onClose: () => void; + anchorElement: HTMLElement; +} + +export function OptionsPopover(props: OptionsPopoverProps) { + const { + core: { uiSettings }, + addBasePath, + } = getServices(); + const isLegacy = uiSettings.get('doc_table:legacy'); + + const mode = isLegacy + ? i18n.translate('discover.openOptionsPopover.legacyTableText', { + defaultMessage: 'Legacy table', + }) + : i18n.translate('discover.openOptionsPopover.dataGridText', { + defaultMessage: 'Data grid', + }); + + return ( + +
+ +

+ Current view mode:{' '} + {mode} +

+
+ + + + + + + {i18n.translate('discover.openOptionsPopover.goToAdvancedSettings', { + defaultMessage: 'Go to Advanced Settings', + })} + +
+
+ ); +} + +export function openOptionsPopover({ + I18nContext, + anchorElement, +}: { + I18nContext: I18nStart['Context']; + anchorElement: HTMLElement; +}) { + if (isOpen) { + return; + } + + isOpen = true; + const container = document.createElement('div'); + const onClose = () => { + ReactDOM.unmountComponentAtNode(container); + document.body.removeChild(container); + isOpen = false; + }; + + document.body.appendChild(container); + + const element = ( + + + + ); + ReactDOM.render(element, container); +} diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index 237da72ae3a52..dbaf07fed18c2 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -317,17 +317,6 @@ export class SearchEmbeddable // Log request to inspector this.inspectorAdapters.requests!.reset(); - const title = i18n.translate('discover.embeddable.inspectorRequestDataTitle', { - defaultMessage: 'Data', - }); - const description = i18n.translate('discover.embeddable.inspectorRequestDescription', { - defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', - }); - - const requestResponder = this.inspectorAdapters.requests!.start(title, { - description, - searchSessionId, - }); this.searchScope.$apply(() => { this.searchScope!.isLoading = true; @@ -340,7 +329,16 @@ export class SearchEmbeddable .fetch$({ abortSignal: this.abortController.signal, sessionId: searchSessionId, - requestResponder, + inspector: { + adapter: this.inspectorAdapters.requests, + title: i18n.translate('discover.embeddable.inspectorRequestDataTitle', { + defaultMessage: 'Data', + }), + description: i18n.translate('discover.embeddable.inspectorRequestDescription', { + defaultMessage: + 'This request queries Elasticsearch to fetch the data for the search.', + }), + }, }) .toPromise(); this.updateOutput({ loading: false, error: undefined }); diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index d3fdb6e72c651..103a06965835e 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -157,7 +157,7 @@ export const uiSettings: Record = { name: i18n.translate('discover.advancedSettings.docTableVersionName', { defaultMessage: 'Use legacy table', }), - value: true, + value: false, description: i18n.translate('discover.advancedSettings.docTableVersionDescription', { defaultMessage: 'Discover uses a new table layout that includes better data sorting, drag-and-drop columns, and a full screen ' + diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 24af29d0cee56..fabe3cd32f34b 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -32,7 +32,7 @@ export abstract class Container< extends Embeddable implements IContainer { public readonly isContainer: boolean = true; - protected readonly children: { + public readonly children: { [key: string]: IEmbeddable | ErrorEmbeddable; } = {}; diff --git a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts index 27164b3cddbc2..b260c594591fa 100644 --- a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts +++ b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts @@ -37,11 +37,14 @@ export const defaultEmbeddableFactoryProvider = < type: def.type, isEditable: def.isEditable.bind(def), getDisplayName: def.getDisplayName.bind(def), + getDescription: def.getDescription ? def.getDescription.bind(def) : () => '', + getIconType: def.getIconType ? def.getIconType.bind(def) : () => 'empty', savedObjectMetaData: def.savedObjectMetaData, telemetry: def.telemetry || (() => ({})), inject: def.inject || ((state: EmbeddableStateWithType) => state), extract: def.extract || ((state: EmbeddableStateWithType) => ({ state, references: [] })), migrations: def.migrations || {}, + grouping: def.grouping, }; return factory; }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts index 7f3277130f90f..6ec035f442dd2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts @@ -14,6 +14,7 @@ import { IContainer } from '../containers/i_container'; import { PropertySpec } from '../types'; import { PersistableState } from '../../../../kibana_utils/common'; import { EmbeddableStateWithType } from '../../../common/types'; +import { UiActionsPresentableGrouping } from '../../../../ui_actions/public'; export interface EmbeddableInstanceConfiguration { id: string; @@ -48,6 +49,12 @@ export interface EmbeddableFactory< readonly savedObjectMetaData?: SavedObjectMetaData; + /** + * Indicates the grouping this factory should appear in a sub-menu. Example, this is used for grouping + * options in the editors menu in Dashboard for creating new embeddables + */ + readonly grouping?: UiActionsPresentableGrouping; + /** * True if is this factory create embeddables that are Containers. Used in the add panel to * conditionally show whether these can be added to another container. It's just not @@ -62,6 +69,16 @@ export interface EmbeddableFactory< */ getDisplayName(): string; + /** + * Returns an EUI Icon type to be displayed in a menu. + */ + getIconType(): string; + + /** + * Returns a description about the embeddable. + */ + getDescription(): string; + /** * If false, this type of embeddable can't be created with the "createNew" functionality. Instead, * use createFromSavedObject, where an existing saved object must first exist. diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts index a64aa32c6e7c4..f2819f2a2e664 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts @@ -33,5 +33,8 @@ export type EmbeddableFactoryDefinition< | 'extract' | 'inject' | 'migrations' + | 'grouping' + | 'getIconType' + | 'getDescription' > >; diff --git a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss index d21911f10f82e..f7ee1f3c741c4 100644 --- a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss +++ b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss @@ -162,12 +162,5 @@ .embPanel__label { position: absolute; padding-left: $euiSizeS; -} - -.embPanel__content[data-error], -.embPanel__content[data-loading] { - pointer-events: none; - filter: grayscale(100%); - /* stylelint-disable-next-line color-named */ - filter: gray; + z-index: $euiZLevel1; } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx index 432897763aa04..1c96945f014c8 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx @@ -61,6 +61,7 @@ test('createNewEmbeddable() add embeddable to container', async () => { getAllFactories={start.getEmbeddableFactories} notifications={core.notifications} SavedObjectFinder={() => null} + showCreateNewMenu /> ) as ReactWrapper; @@ -112,6 +113,7 @@ test('selecting embeddable in "Create new ..." list calls createNewEmbeddable()' getAllFactories={start.getEmbeddableFactories} notifications={core.notifications} SavedObjectFinder={(props) => } + showCreateNewMenu /> ) as ReactWrapper; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx index 8caec4a4428c3..6d6a68d7e5e2a 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx @@ -26,6 +26,7 @@ interface Props { getAllFactories: EmbeddableStart['getEmbeddableFactories']; notifications: CoreSetup['notifications']; SavedObjectFinder: React.ComponentType; + showCreateNewMenu?: boolean; } interface State { @@ -134,7 +135,9 @@ export class AddPanelFlyout extends React.Component { defaultMessage: 'No matching objects found.', })} > - + {this.props.showCreateNewMenu ? ( + + ) : null} ); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx index bed97c82095c7..f0c6e81644b3d 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx @@ -20,6 +20,7 @@ export function openAddPanelFlyout(options: { overlays: OverlayStart; notifications: NotificationsStart; SavedObjectFinder: React.ComponentType; + showCreateNewMenu?: boolean; }): OverlayRef { const { embeddable, @@ -28,6 +29,7 @@ export function openAddPanelFlyout(options: { overlays, notifications, SavedObjectFinder, + showCreateNewMenu, } = options; const flyoutSession = overlays.openFlyout( toMountPoint( @@ -42,6 +44,7 @@ export function openAddPanelFlyout(options: { getAllFactories={getAllFactories} notifications={notifications} SavedObjectFinder={SavedObjectFinder} + showCreateNewMenu={showCreateNewMenu} /> ), { diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 220039de2f34e..2a577e6167be5 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -160,7 +160,7 @@ export abstract class Container = { // (undocumented) addNewEmbeddable = IEmbeddable>(type: string, explicitInput: Partial): Promise; // (undocumented) - protected readonly children: { + readonly children: { [key: string]: IEmbeddable | ErrorEmbeddable; }; // (undocumented) @@ -378,8 +378,12 @@ export interface EmbeddableFactory; createFromSavedObject(savedObjectId: string, input: Partial, parent?: IContainer): Promise; getDefaultInput(partial: Partial): Partial; + getDescription(): string; getDisplayName(): string; getExplicitInput(): Promise>; + getIconType(): string; + // Warning: (ae-forgotten-export) The symbol "PresentableGrouping" needs to be exported by the entry point index.d.ts + readonly grouping?: PresentableGrouping; readonly isContainerType: boolean; readonly isEditable: () => Promise; // Warning: (ae-forgotten-export) The symbol "SavedObjectMetaData" needs to be exported by the entry point index.d.ts @@ -393,7 +397,7 @@ export interface EmbeddableFactory = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations'>>; +export type EmbeddableFactoryDefinition = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations' | 'grouping' | 'getIconType' | 'getDescription'>>; // Warning: (ae-missing-release-tag) "EmbeddableFactoryNotFoundError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -724,6 +728,7 @@ export function openAddPanelFlyout(options: { overlays: OverlayStart_2; notifications: NotificationsStart_2; SavedObjectFinder: React.ComponentType; + showCreateNewMenu?: boolean; }): OverlayRef_2; // Warning: (ae-missing-release-tag) "OutputSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/plugins/es_ui_shared/tsconfig.json b/src/plugins/es_ui_shared/tsconfig.json index 9bcda2e0614de..5f136d09b2ce4 100644 --- a/src/plugins/es_ui_shared/tsconfig.json +++ b/src/plugins/es_ui_shared/tsconfig.json @@ -16,6 +16,6 @@ ], "references": [ { "path": "../../core/tsconfig.json" }, - { "path": "../data/tsconfig.json" }, + { "path": "../data/tsconfig.json" } ] } diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index f16c1c7104417..1fa19189b8c84 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -438,10 +438,10 @@ export const getSavedObjects = (): SavedObject[] => [ attributes: { title: 'kibana_sample_data_flights', timeFieldName: 'timestamp', - fields: - '[{"name":"hour_of_day","type":"number","count":0,"scripted":true,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","searchable":true,"aggregatable":true,"readFromDocValues":false}]', fieldFormatMap: '{"hour_of_day":{"id":"number","params":{"pattern":"00"}},"AvgTicketPrice":{"id":"number","params":{"pattern":"$0,0.[00]"}}}', + runtimeFieldMap: + '{"hour_of_day":{"type":"long","script":{"source":"emit(doc[\'timestamp\'].value.hourOfDay);"}}}', }, references: [], }, diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts index 8a3469fe4f3c0..a68d6bfe9cc58 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts @@ -275,9 +275,9 @@ export const getSavedObjects = (): SavedObject[] => [ attributes: { title: 'kibana_sample_data_logs', timeFieldName: 'timestamp', - fields: - '[{"name":"hour_of_day","type":"number","count":0,"scripted":true,"script":"doc[\'timestamp\'].value.getHour()","lang":"painless","searchable":true,"aggregatable":true,"readFromDocValues":false}]', fieldFormatMap: '{"hour_of_day":{}}', + runtimeFieldMap: + '{"hour_of_day":{"type":"long","script":{"source":"emit(doc[\'timestamp\'].value.getHour());"}}}', }, references: [], }, diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index a20c3e350222f..e5ff33d5c199d 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -7,7 +7,7 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter, Logger, RequestHandlerContext } from 'src/core/server'; +import { IRouter, Logger, IScopedClusterClient } from 'src/core/server'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; import { @@ -22,7 +22,7 @@ const insertDataIntoIndex = ( dataIndexConfig: any, index: string, nowReference: string, - context: RequestHandlerContext, + esClient: IScopedClusterClient, logger: Logger ) => { function updateTimestamps(doc: any) { @@ -51,9 +51,11 @@ const insertDataIntoIndex = ( bulk.push(insertCmd); bulk.push(updateTimestamps(doc)); }); - const resp = await context.core.elasticsearch.legacy.client.callAsCurrentUser('bulk', { + + const { body: resp } = await esClient.asCurrentUser.bulk({ body: bulk, }); + if (resp.errors) { const errMsg = `sample_data install errors while bulk inserting. Elasticsearch response: ${JSON.stringify( resp, @@ -100,7 +102,7 @@ export function createInstallRoute( // clean up any old installation of dataset try { - await context.core.elasticsearch.legacy.client.callAsCurrentUser('indices.delete', { + await context.core.elasticsearch.client.asCurrentUser.indices.delete({ index, }); } catch (err) { @@ -108,17 +110,13 @@ export function createInstallRoute( } try { - const createIndexParams = { + await context.core.elasticsearch.client.asCurrentUser.indices.create({ index, body: { settings: { index: { number_of_shards: 1, auto_expand_replicas: '0-1' } }, mappings: { properties: dataIndexConfig.fields }, }, - }; - await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'indices.create', - createIndexParams - ); + }); } catch (err) { const errMsg = `Unable to create sample data index "${index}", error: ${err.message}`; logger.warn(errMsg); @@ -130,7 +128,7 @@ export function createInstallRoute( dataIndexConfig, index, nowReference, - context, + context.core.elasticsearch.client, logger ); (counts as any)[index] = count; diff --git a/src/plugins/home/server/services/sample_data/routes/list.ts b/src/plugins/home/server/services/sample_data/routes/list.ts index 86e286644f936..72d8c31cbafd7 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.ts @@ -36,22 +36,20 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc const dataIndexConfig = sampleDataset.dataIndices[i]; const index = createIndexName(sampleDataset.id, dataIndexConfig.id); try { - const indexExists = await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'indices.exists', - { index } - ); + const { + body: indexExists, + } = await context.core.elasticsearch.client.asCurrentUser.indices.exists({ + index, + }); if (!indexExists) { sampleDataset.status = NOT_INSTALLED; return; } - const { count } = await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'count', - { - index, - } - ); - if (count === 0) { + const { body: count } = await context.core.elasticsearch.client.asCurrentUser.count({ + index, + }); + if (count.count === 0) { sampleDataset.status = NOT_INSTALLED; return; } diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index aa8ed67cf840a..3108c06492dd8 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -28,11 +28,7 @@ export function createUninstallRoute( async ( { core: { - elasticsearch: { - legacy: { - client: { callAsCurrentUser }, - }, - }, + elasticsearch: { client: esClient }, savedObjects: { getClient: getSavedObjectsClient, typeRegistry }, }, }, @@ -50,7 +46,9 @@ export function createUninstallRoute( const index = createIndexName(sampleDataset.id, dataIndexConfig.id); try { - await callAsCurrentUser('indices.delete', { index }); + await esClient.asCurrentUser.indices.delete({ + index, + }); } catch (err) { return response.customError({ statusCode: err.status, diff --git a/src/plugins/home/server/services/sample_data/usage/collector.ts b/src/plugins/home/server/services/sample_data/usage/collector.ts index 81958a2e3c878..df7d485c1f6fa 100644 --- a/src/plugins/home/server/services/sample_data/usage/collector.ts +++ b/src/plugins/home/server/services/sample_data/usage/collector.ts @@ -6,22 +6,17 @@ * Side Public License, v 1. */ -import { PluginInitializerContext } from 'kibana/server'; -import { first } from 'rxjs/operators'; +import type { PluginInitializerContext } from 'kibana/server'; +import type { UsageCollectionSetup } from '../../../../../usage_collection/server'; import { fetchProvider, TelemetryResponse } from './collector_fetch'; -import { UsageCollectionSetup } from '../../../../../usage_collection/server'; -export async function makeSampleDataUsageCollector( +export function makeSampleDataUsageCollector( usageCollection: UsageCollectionSetup, context: PluginInitializerContext ) { - let index: string; - try { - const config = await context.config.legacy.globalConfig$.pipe(first()).toPromise(); - index = config.kibana.index; - } catch (err) { - return; // kibana plugin is not enabled (test environment) - } + const config = context.config.legacy.get(); + const index = config.kibana.index; + const collector = usageCollection.makeUsageCollector({ type: 'sample-data', fetch: fetchProvider(index), diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap index 70b638d5d0b8d..21248ac9d1dc0 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap @@ -22,6 +22,7 @@ exports[`CreateIndexPatternWizard renders index pattern step when there are indi "ELASTIC_WEBSITE_URL": "htts://jestTest.elastic.co", "links": Object { "indexPatterns": Object {}, + "runtimeFields": Object {}, "scriptedFields": Object {}, }, } @@ -72,6 +73,7 @@ exports[`CreateIndexPatternWizard renders the empty state when there are no indi "ELASTIC_WEBSITE_URL": "htts://jestTest.elastic.co", "links": Object { "indexPatterns": Object {}, + "runtimeFields": Object {}, "scriptedFields": Object {}, }, } @@ -116,6 +118,7 @@ exports[`CreateIndexPatternWizard renders time field step when step is set to 2 "ELASTIC_WEBSITE_URL": "htts://jestTest.elastic.co", "links": Object { "indexPatterns": Object {}, + "runtimeFields": Object {}, "scriptedFields": Object {}, }, } @@ -160,6 +163,7 @@ exports[`CreateIndexPatternWizard renders when there are no indices but there ar "ELASTIC_WEBSITE_URL": "htts://jestTest.elastic.co", "links": Object { "indexPatterns": Object {}, + "runtimeFields": Object {}, "scriptedFields": Object {}, }, } @@ -204,6 +208,7 @@ exports[`CreateIndexPatternWizard shows system indices even if there are no othe "ELASTIC_WEBSITE_URL": "htts://jestTest.elastic.co", "links": Object { "indexPatterns": Object {}, + "runtimeFields": Object {}, "scriptedFields": Object {}, }, } diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap index 5218ebd1b4ad4..f4eb2a0e74089 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap @@ -1,49 +1,205 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Header should render normally 1`] = ` -
-
-

- - Scripted fields - -

-

- - You can use scripted fields in visualizations and display them in your documents. However, you cannot search scripted fields. - -

-
-
-
- + + instead. + + +

+
+ +
+
+ - - - Add scripted field - - - - - - + + + + + + + + + + `; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.test.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.test.tsx index 3e2da10cb1473..609769690dbae 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.test.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.test.tsx @@ -7,22 +7,31 @@ */ import React from 'react'; -import { render } from 'enzyme'; +import { mount } from 'enzyme'; import { RouteComponentProps } from 'react-router-dom'; import { ScopedHistory } from 'kibana/public'; import { scopedHistoryMock } from '../../../../../../../../core/public/mocks'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; +import { mockManagementPlugin } from '../../../../../mocks'; import { Header } from './header'; describe('Header', () => { + const mockedContext = mockManagementPlugin.createIndexPatternManagmentContext(); test('should render normally', () => { - const component = render( + const component = mount( + />, + { + wrappingComponent: KibanaContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } ); expect(component).toMatchSnapshot(); diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.tsx index 607f9ff804e7d..22da83b179652 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.tsx @@ -8,50 +8,61 @@ import React from 'react'; import { withRouter, RouteComponentProps } from 'react-router-dom'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText, EuiLink, EuiIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { ScopedHistory } from 'kibana/public'; -import { reactRouterNavigate } from '../../../../../../../kibana_react/public'; +import { reactRouterNavigate, useKibana } from '../../../../../../../kibana_react/public'; +import { IndexPatternManagmentContext } from '../../../../../types'; interface HeaderProps extends RouteComponentProps { indexPatternId: string; history: ScopedHistory; } -export const Header = withRouter(({ indexPatternId, history }: HeaderProps) => ( - - - -

- -

-
- -

+export const Header = withRouter(({ indexPatternId, history }: HeaderProps) => { + const docLinks = useKibana().services.docLinks?.links; + return ( + + + +

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

+
+
+ + + -

- -
- - - - - - -
-)); + + + + ); +}); diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/header/__snapshots__/header.test.tsx.snap b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/header/__snapshots__/header.test.tsx.snap index daa8e4a1c7063..1f56e3caeaf6b 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/header/__snapshots__/header.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/header/__snapshots__/header.test.tsx.snap @@ -2,18 +2,9 @@ exports[`Header should render normally 1`] = ` - -

- -

-
-

( <> - -

- -

- - +

+ +

+

+ + + , + "scriptsInAggregation": + + , + } + } + > + + Familiarize yourself with + + + + and + + + + before using this feature. Scripted fields can be used to display and aggregate calculated values. As such, they can be very slow and, if done incorrectly, can cause Kibana to become unusable. + + +

+
+
+ +
+ } @@ -38,12 +126,13 @@ exports[`ScriptingWarningCallOut should render normally 1`] = ` className="euiCallOutHeader__title" > - Proceed with caution + Scripted fields are deprecated @@ -54,113 +143,57 @@ exports[`ScriptingWarningCallOut should render normally 1`] = `
-

- - -   - - , - "scriptsInAggregation": - -   - - , - } - } + +

- - Please familiarize yourself with - + + + , + } + } > - - - and with - - - - before using scripted fields. - - -

-

- - - Scripted fields can be used to display and aggregate calculated values. As such, they can be very slow, and if done incorrectly, can cause Kibana to be unusable. There's no safety net here. If you make a typo, unexpected exceptions will be thrown all over the place! - - -

+ + + . + + +

+
+
diff --git a/src/plugins/index_pattern_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.tsx b/src/plugins/index_pattern_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.tsx index e52136476dd03..d992a3fc5c192 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.tsx +++ b/src/plugins/index_pattern_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.tsx @@ -8,7 +8,7 @@ import React, { Fragment } from 'react'; -import { EuiCallOut, EuiIcon, EuiLink, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -20,56 +20,67 @@ export interface ScriptingWarningCallOutProps { } export const ScriptingWarningCallOut = ({ isVisible = false }: ScriptingWarningCallOutProps) => { - const docLinksScriptedFields = useKibana().services.docLinks?.links - .scriptedFields; + const docLinks = useKibana().services.docLinks?.links; return isVisible ? ( - - } - color="warning" - iconType="alert" - > +

+ -   - ), scriptsInAggregation: ( - + -   - ), }} />

-

+ + + -

+ } + > + +

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

+
diff --git a/src/plugins/index_pattern_management/public/mocks.ts b/src/plugins/index_pattern_management/public/mocks.ts index 606f9edafbca9..3462131e50463 100644 --- a/src/plugins/index_pattern_management/public/mocks.ts +++ b/src/plugins/index_pattern_management/public/mocks.ts @@ -69,6 +69,7 @@ const docLinks = { links: { indexPatterns: {}, scriptedFields: {}, + runtimeFields: {}, } as any, }; diff --git a/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss b/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss index f290b3c7c5f89..8a4545672de3c 100644 --- a/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss +++ b/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss @@ -2,8 +2,13 @@ line-height: $euiButtonHeight; // Keeps alignment of text and chart icon background-color: $euiColorEmptyShade; + // todo: once issue https://github.com/elastic/eui/issues/4730 is merged, this code might be safe to remove // Some toolbar buttons are just icons, but EuiButton comes with margin and min-width that need to be removed min-width: 0; + @include kbnThemeStyle('v8') { + border-width: $euiBorderWidthThin; + border-style: solid; + } &[class*='--text'] { // Lighten the border color for all states diff --git a/src/plugins/kibana_usage_collection/README.md b/src/plugins/kibana_usage_collection/README.md index 9ad2bd987e1f4..9e9438b1b5fee 100644 --- a/src/plugins/kibana_usage_collection/README.md +++ b/src/plugins/kibana_usage_collection/README.md @@ -4,6 +4,7 @@ This plugin registers the basic usage collectors from Kibana: - [Application Usage](./server/collectors/application_usage/README.md) - Core Metrics +- [Config Usage](./server/collectors/config_usage/README.md) - CSP configuration - Kibana: Number of Saved Objects per type - Localization data @@ -11,8 +12,3 @@ This plugin registers the basic usage collectors from Kibana: - Ops stats - UI Counts - UI Metrics - - - - - diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 693e9132fe536..e033da875080f 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -148,6 +148,7 @@ export const applicationUsageSchema = { ml: commonSchema, monitoring: commonSchema, 'observability-overview': commonSchema, + osquery: commonSchema, security_account: commonSchema, security_access_agreement: commonSchema, security_capture_url: commonSchema, // It's a forward app so we'll likely never report it diff --git a/src/plugins/kibana_usage_collection/server/collectors/config_usage/README.md b/src/plugins/kibana_usage_collection/server/collectors/config_usage/README.md new file mode 100644 index 0000000000000..b476244e5082f --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/config_usage/README.md @@ -0,0 +1,64 @@ +# Config Usage Collector + +The config usage collector reports non-default kibana configs. + +All non-default configs except booleans and numbers will be reported as `[redacted]` unless otherwise specified via `config.exposeToUsage` in the plugin config descriptor. + +```ts +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +export const configSchema = schema.object({ + usageCounters: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + retryCount: schema.number({ defaultValue: 1 }), + bufferDuration: schema.duration({ defaultValue: '5s' }), + }), + uiCounters: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + debug: schema.boolean({ defaultValue: schema.contextRef('dev') }), + }), + maximumWaitTimeForAllCollectorsInS: schema.number({ + defaultValue: DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S, + }), +}); + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToUsage: { + uiCounters: true, + usageCounters: { + bufferDuration: true, + }, + maximumWaitTimeForAllCollectorsInS: false, + }, +}; +``` + +In the above example setting `uiCounters: true` in the `exposeToUsage` property marks all configs +under the path `uiCounters` as safe. The collector will send the actual non-default config value +when setting an exact config or its parent path to `true`. + +Settings the config path or its parent path to `false` will explicitly mark this config as unsafe. +The collector will send `[redacted]` for non-default configs +when setting an exact config or its parent path to `false`. + +### Output of the collector + +```json +{ + "kibana_config_usage": { + "xpack.apm.serviceMapTraceIdBucketSize": 30, + "elasticsearch.username": "[redacted]", + "elasticsearch.password": "[redacted]", + "plugins.paths": "[redacted]", + "server.port": 5603, + "server.basePath": "[redacted]", + "server.rewriteBasePath": true, + "logging.json": false, + "usageCollection.uiCounters.debug": true + } +} +``` + +Note that arrays of objects will be reported as `[redacted]` and cannot be explicitly marked as safe. \ No newline at end of file diff --git a/src/plugins/kibana_usage_collection/server/collectors/config_usage/index.ts b/src/plugins/kibana_usage_collection/server/collectors/config_usage/index.ts new file mode 100644 index 0000000000000..5d37cfe5957ab --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/config_usage/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { registerConfigUsageCollector } from './register_config_usage_collector'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts new file mode 100644 index 0000000000000..7d4f03fd30edf --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + Collector, + createUsageCollectionSetupMock, + createCollectorFetchContextMock, +} from '../../../../usage_collection/server/mocks'; +import { registerConfigUsageCollector } from './register_config_usage_collector'; +import { coreUsageDataServiceMock, loggingSystemMock } from '../../../../../core/server/mocks'; +import type { ConfigUsageData } from '../../../../../core/server'; + +const logger = loggingSystemMock.createLogger(); + +describe('kibana_config_usage', () => { + let collector: Collector; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const collectorFetchContext = createCollectorFetchContextMock(); + const coreUsageDataStart = coreUsageDataServiceMock.createStartContract(); + const mockConfigUsage = (Symbol('config usage telemetry') as any) as ConfigUsageData; + coreUsageDataStart.getConfigsUsageData.mockResolvedValue(mockConfigUsage); + + beforeAll(() => registerConfigUsageCollector(usageCollectionMock, () => coreUsageDataStart)); + + test('registered collector is set', () => { + expect(collector).not.toBeUndefined(); + expect(collector.type).toBe('kibana_config_usage'); + }); + + test('fetch', async () => { + expect(await collector.fetch(collectorFetchContext)).toEqual(mockConfigUsage); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.ts new file mode 100644 index 0000000000000..ad7f570432abf --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.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 { UsageCollectionSetup } from '../../../../usage_collection/server'; +import { ConfigUsageData, CoreUsageDataStart } from '../../../../../core/server'; + +export function registerConfigUsageCollector( + usageCollection: UsageCollectionSetup, + getCoreUsageDataService: () => CoreUsageDataStart +) { + const collector = usageCollection.makeUsageCollector({ + type: 'kibana_config_usage', + isReady: () => typeof getCoreUsageDataService() !== 'undefined', + /** + * No schema for this collector. + * This collector will collect non-default configs from all plugins. + * Mapping each config to the schema is inconvenient for developers + * and would result in 100's of extra field mappings. + * + * We'll experiment with flattened type and runtime fields before comitting to a schema. + */ + schema: {}, + fetch: async () => { + const coreUsageDataService = getCoreUsageDataService(); + if (!coreUsageDataService) { + return; + } + + return await coreUsageDataService.getConfigsUsageData(); + }, + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts similarity index 89% rename from src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts rename to src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts index cbc38129fdddf..b671a9f93d369 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts @@ -9,11 +9,11 @@ import { Collector, createUsageCollectionSetupMock, + createCollectorFetchContextMock, } from '../../../../usage_collection/server/mocks'; -import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; -import { registerCoreUsageCollector } from '.'; +import { registerCoreUsageCollector } from './core_usage_collector'; import { coreUsageDataServiceMock, loggingSystemMock } from '../../../../../core/server/mocks'; -import { CoreUsageData } from 'src/core/server/'; +import type { CoreUsageData } from '../../../../../core/server'; const logger = loggingSystemMock.createLogger(); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index 5192e2300e3df..3f39b5563ebc0 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -238,6 +238,38 @@ export function getCoreUsageCollector( }, }, }, + securityResponseHeaders: { + strictTransportSecurity: { + type: 'keyword', + _meta: { + description: 'The strictTransportSecurity response header, "NULL" if disabled.', + }, + }, + xContentTypeOptions: { + type: 'keyword', + _meta: { + description: 'The xContentTypeOptions response header, "NULL" if disabled.', + }, + }, + referrerPolicy: { + type: 'keyword', + _meta: { description: 'The referrerPolicy response header, "NULL" if disabled.' }, + }, + permissionsPolicyConfigured: { + type: 'boolean', + _meta: { + description: + 'Indicates if the permissionsPolicy response header has been configured.', + }, + }, + disableEmbedding: { + type: 'boolean', + _meta: { + description: + 'Indicates if security headers to disable embedding have been configured.', + }, + }, + }, }, logging: { diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 522860e58918c..94ed0eefe7a06 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -15,6 +15,7 @@ export { registerCloudProviderUsageCollector } from './cloud'; export { registerCspCollector } from './csp'; export { registerCoreUsageCollector } from './core'; export { registerLocalizationUsageCollector } from './localization'; +export { registerConfigUsageCollector } from './config_usage'; export { registerUiCountersUsageCollector, registerUiCounterSavedObjectType, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 142bcef521c15..dfe31b1da3643 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -432,10 +432,6 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'text', _meta: { description: 'Non-default value of setting.' }, }, - 'observability:enableAlertingExperience': { - type: 'boolean', - _meta: { description: 'Non-default value of setting.' }, - }, 'labs:presentation:unifiedToolbar': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index b457adecc1a79..b8bc06d8a6a29 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -32,7 +32,6 @@ export interface UsageStats { 'securitySolution:rulesTableRefresh': string; 'apm:enableSignificantTerms': boolean; 'apm:enableServiceOverview': boolean; - 'observability:enableAlertingExperience': boolean; 'observability:enableInspectEsQueries': boolean; 'visualize:enableLabs': boolean; 'visualization:heatmap:maxBuckets': number; diff --git a/src/plugins/kibana_usage_collection/server/plugin.test.ts b/src/plugins/kibana_usage_collection/server/plugin.test.ts index 86204ed30e656..450c610afc620 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.test.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.ts @@ -93,6 +93,10 @@ describe('kibana_usage_collection', () => { "isReady": false, "type": "core", }, + Object { + "isReady": false, + "type": "kibana_config_usage", + }, Object { "isReady": true, "type": "localization", diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index a27b8dff57b67..c144384e0882f 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -35,6 +35,7 @@ import { registerUiCountersUsageCollector, registerUiCounterSavedObjectType, registerUiCountersRollups, + registerConfigUsageCollector, registerUsageCountersRollups, registerUsageCountersUsageCollector, } from './collectors'; @@ -122,6 +123,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { registerCloudProviderUsageCollector(usageCollection); registerCspCollector(usageCollection, coreSetup.http); registerCoreUsageCollector(usageCollection, getCoreUsageDataService); + registerConfigUsageCollector(usageCollection, getCoreUsageDataService); registerLocalizationUsageCollector(usageCollection, coreSetup.i18n); } } diff --git a/src/plugins/maps_ems/common/ems_defaults.ts b/src/plugins/maps_ems/common/ems_defaults.ts index 6d99f2041484c..d29d47fb19dbb 100644 --- a/src/plugins/maps_ems/common/ems_defaults.ts +++ b/src/plugins/maps_ems/common/ems_defaults.ts @@ -12,3 +12,7 @@ export const DEFAULT_EMS_TILE_API_URL = 'https://tiles.maps.elastic.co'; export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v7.12'; export const DEFAULT_EMS_FONT_LIBRARY_URL = 'https://tiles.maps.elastic.co/fonts/{fontstack}/{range}.pbf'; + +export const DEFAULT_EMS_ROADMAP_ID = 'road_map'; +export const DEFAULT_EMS_ROADMAP_DESATURATED_ID = 'road_map_desaturated'; +export const DEFAULT_EMS_DARKMAP_ID = 'dark_map'; diff --git a/src/plugins/maps_ems/config.ts b/src/plugins/maps_ems/config.ts index e74a8f5cec29c..1deff36a10e45 100644 --- a/src/plugins/maps_ems/config.ts +++ b/src/plugins/maps_ems/config.ts @@ -13,6 +13,9 @@ import { DEFAULT_EMS_LANDING_PAGE_URL, DEFAULT_EMS_TILE_API_URL, DEFAULT_EMS_FILE_API_URL, + DEFAULT_EMS_ROADMAP_ID, + DEFAULT_EMS_ROADMAP_DESATURATED_ID, + DEFAULT_EMS_DARKMAP_ID, } from './common'; const tileMapConfigOptionsSchema = schema.object({ @@ -77,9 +80,9 @@ export const emsConfigSchema = schema.object({ defaultValue: DEFAULT_EMS_FONT_LIBRARY_URL, }), emsTileLayerId: schema.object({ - bright: schema.string({ defaultValue: 'road_map' }), - desaturated: schema.string({ defaultValue: 'road_map_desaturated' }), - dark: schema.string({ defaultValue: 'dark_map' }), + bright: schema.string({ defaultValue: DEFAULT_EMS_ROADMAP_ID }), + desaturated: schema.string({ defaultValue: DEFAULT_EMS_ROADMAP_DESATURATED_ID }), + dark: schema.string({ defaultValue: DEFAULT_EMS_DARKMAP_ID }), }), }); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index 79c3d4cca7ace..b8022201acf59 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -1,4 +1,3 @@ - .solutionToolbarButton { line-height: $euiButtonHeight; // Keeps alignment of text and chart icon background-color: $euiColorEmptyShade; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx index 5de8e24ef5f0d..ee1bbd64b5f87 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx @@ -12,17 +12,19 @@ import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/butt import './button.scss'; -export interface Props extends Pick { +export interface Props + extends Pick { label: string; primary?: boolean; + isDarkModeEnabled?: boolean; } -export const SolutionToolbarButton = ({ label, primary, ...rest }: Props) => ( +export const SolutionToolbarButton = ({ label, primary, className, ...rest }: Props) => ( {label} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx index fbb34e165190d..33850005b498b 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx @@ -20,14 +20,20 @@ type AllowedPopoverProps = Omit< export type Props = AllowedButtonProps & AllowedPopoverProps; -export const SolutionToolbarPopover = ({ label, iconType, primary, ...popover }: Props) => { +export const SolutionToolbarPopover = ({ + label, + iconType, + primary, + iconSide, + ...popover +}: Props) => { const [isOpen, setIsOpen] = useState(false); const onButtonClick = () => setIsOpen((status) => !status); const closePopover = () => setIsOpen(false); const button = ( - + ); return ( diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss new file mode 100644 index 0000000000000..c3d89f430d70c --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +// Temporary fix for lensApp icon not support ghost color +.solutionToolbar__primaryButton--dark { + .euiIcon path { + fill: $euiColorInk; + } +} + +.solutionToolbar__primaryButton--light { + .euiIcon path { + fill: $euiColorGhost; + } +} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx index e2ef75e45a404..dcf16228ac63b 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx @@ -10,6 +10,20 @@ import React from 'react'; import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button'; -export const PrimaryActionButton = (props: Omit) => ( - +import './primary_button.scss'; + +export interface Props extends Omit { + isDarkModeEnabled?: boolean; +} + +export const PrimaryActionButton = ({ isDarkModeEnabled, ...props }: Props) => ( + ); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss index 639ff5bf2a117..870a9a945ed5d 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -2,4 +2,17 @@ .quickButtonGroup__button { background-color: $euiColorEmptyShade; } + + // Temporary fix for two tone icons to make them monochrome + .quickButtonGroup__button--dark { + .euiIcon path { + fill: $euiColorGhost; + } + } + // Temporary fix for two tone icons to make them monochrome + .quickButtonGroup__button--light { + .euiIcon path { + fill: $euiColorInk; + } + } } diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx index 58f8bd803b636..eb0a395548cd9 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx @@ -17,23 +17,27 @@ import './quick_group.scss'; export interface QuickButtonProps extends Pick { createType: string; onClick: () => void; + isDarkModeEnabled?: boolean; } export interface Props { buttons: QuickButtonProps[]; } -type Option = EuiButtonGroupOptionProps & Omit; +type Option = EuiButtonGroupOptionProps & + Omit; export const QuickButtonGroup = ({ buttons }: Props) => { const buttonGroupOptions: Option[] = buttons.map((button: QuickButtonProps, index) => { - const { createType: label, ...rest } = button; + const { createType: label, isDarkModeEnabled, ...rest } = button; const title = strings.getAriaButtonLabel(label); return { ...rest, 'aria-label': title, - className: 'quickButtonGroup__button', + className: `quickButtonGroup__button ${ + isDarkModeEnabled ? 'quickButtonGroup__button--dark' : 'quickButtonGroup__button--light' + }`, id: `${htmlIdGenerator()()}${index}`, label, title, @@ -46,7 +50,7 @@ export const QuickButtonGroup = ({ buttons }: Props) => { return ( { +export const SolutionToolbar = ({ isDarkModeEnabled, children }: Props) => { const { primaryActionButton, quickButtonGroup, @@ -49,8 +50,10 @@ export const SolutionToolbar = ({ children }: Props) => { return ( {primaryActionButton} diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 9c5f65de40955..fd3ae89419297 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -19,6 +19,7 @@ export { LazySavedObjectSaveModalDashboard, withSuspense, } from './components'; + export { AddFromLibraryButton, PrimaryActionButton, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 2659fffa0bd9d..842496815c15c 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -3954,6 +3954,137 @@ } } }, + "osquery": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "Always `main`" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 90 days" + } + }, + "views": { + "type": "array", + "items": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "The application view being tracked" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application sub view since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application sub view is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 90 days" + } + } + } + } + } + } + }, "security_account": { "properties": { "appId": { @@ -6742,6 +6873,40 @@ } } } + }, + "securityResponseHeaders": { + "properties": { + "strictTransportSecurity": { + "type": "keyword", + "_meta": { + "description": "The strictTransportSecurity response header, \"NULL\" if disabled." + } + }, + "xContentTypeOptions": { + "type": "keyword", + "_meta": { + "description": "The xContentTypeOptions response header, \"NULL\" if disabled." + } + }, + "referrerPolicy": { + "type": "keyword", + "_meta": { + "description": "The referrerPolicy response header, \"NULL\" if disabled." + } + }, + "permissionsPolicyConfigured": { + "type": "boolean", + "_meta": { + "description": "Indicates if the permissionsPolicy response header has been configured." + } + }, + "disableEmbedding": { + "type": "boolean", + "_meta": { + "description": "Indicates if security headers to disable embedding have been configured." + } + } + } } } }, @@ -8166,12 +8331,6 @@ "description": "Non-default value of setting." } }, - "observability:enableAlertingExperience": { - "type": "boolean", - "_meta": { - "description": "Non-default value of setting." - } - }, "labs:presentation:unifiedToolbar": { "type": "boolean", "_meta": { diff --git a/src/plugins/telemetry/schema/oss_root.json b/src/plugins/telemetry/schema/oss_root.json index 658f5ee4e66da..c4dd1096a6e98 100644 --- a/src/plugins/telemetry/schema/oss_root.json +++ b/src/plugins/telemetry/schema/oss_root.json @@ -183,8 +183,8 @@ }, "plugins": { "properties": { - "THIS_WILL_BE_REPLACED_BY_THE_PLUGINS_JSON": { - "type": "text" + "kibana_config_usage": { + "type": "pass_through" } } } diff --git a/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts b/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts index d639b053565d1..01d89c5731158 100644 --- a/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts +++ b/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts @@ -17,7 +17,9 @@ export class TelemetrySavedObjectsClient extends SavedObjectsClient { * Find the SavedObjects matching the search query in all the Spaces by default * @param options */ - async find(options: SavedObjectsFindOptions): Promise> { + async find( + options: SavedObjectsFindOptions + ): Promise> { return super.find({ namespaces: ['*'], ...options }); } } diff --git a/src/plugins/ui_actions/public/public.api.md b/src/plugins/ui_actions/public/public.api.md index 44d911d336b19..f8c473a3e2c0a 100644 --- a/src/plugins/ui_actions/public/public.api.md +++ b/src/plugins/ui_actions/public/public.api.md @@ -138,7 +138,7 @@ export interface UiActionsActionDefinition exte // @public export interface UiActionsPresentable { getDisplayName(context: Context): string; - getDisplayNameTooltip(context: Context): string; + getDisplayNameTooltip?(context: Context): string; getHref?(context: Context): Promise; getIconType(context: Context): string | undefined; readonly grouping?: UiActionsPresentableGrouping; diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/util/presentable.ts index 319a271ceb726..9e690b9ef3c08 100644 --- a/src/plugins/ui_actions/public/util/presentable.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -43,7 +43,7 @@ export interface Presentable { * Returns tooltip text which should be displayed when user hovers this object. * Should return empty string if tooltip should not be displayed. */ - getDisplayNameTooltip(context: Context): string; + getDisplayNameTooltip?(context: Context): string; /** * This method should return a link if this item can be clicked on. The link diff --git a/src/plugins/usage_collection/server/config.ts b/src/plugins/usage_collection/server/config.ts index cd6f6b9d81396..faf8ce7535e8a 100644 --- a/src/plugins/usage_collection/server/config.ts +++ b/src/plugins/usage_collection/server/config.ts @@ -38,4 +38,9 @@ export const config: PluginConfigDescriptor = { exposeToBrowser: { uiCounters: true, }, + exposeToUsage: { + usageCounters: { + bufferDuration: true, + }, + }, }; diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts index c800bce6390c9..8a76368c8cd9d 100644 --- a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts @@ -187,10 +187,13 @@ describe('UsageCountersService', () => { await tick(); // number of incrementCounter calls + number of retries expect(mockIncrementCounter).toBeCalledTimes(2 + 1); - expect(logger.debug).toHaveBeenNthCalledWith(1, 'Store counters into savedObjects', [ - mockError, - 'pass', - ]); + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Store counters into savedObjects', { + kibana: { + usageCounters: { + results: [mockError, 'pass'], + }, + }, + }); }); it('buffers counters within `bufferDurationMs` time', async () => { diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts index 88ca9f6358926..a698ea3db5bad 100644 --- a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts @@ -13,7 +13,7 @@ import { SavedObjectsServiceSetup, SavedObjectsServiceStart, } from 'src/core/server'; -import type { Logger } from 'src/core/server'; +import type { Logger, LogMeta } from 'src/core/server'; import moment from 'moment'; import { CounterMetric, UsageCounter } from './usage_counter'; @@ -23,6 +23,10 @@ import { serializeCounterKey, } from './saved_objects'; +interface UsageCountersLogMeta extends LogMeta { + kibana: { usageCounters: { results: unknown[] } }; +} + export interface UsageCountersServiceDeps { logger: Logger; retryCount: number; @@ -116,7 +120,11 @@ export class UsageCountersService { rxOp.concatMap((counters) => this.storeDate$(counters, internalRepository)) ) .subscribe((results) => { - this.logger.debug('Store counters into savedObjects', results); + this.logger.debug('Store counters into savedObjects', { + kibana: { + usageCounters: { results }, + }, + }); }); this.flushCache$.next(); diff --git a/src/plugins/vis_type_table/public/components/table_visualization.scss b/src/plugins/vis_type_table/public/components/table_visualization.scss index 28dbf17b18739..21c235adf6db2 100644 --- a/src/plugins/vis_type_table/public/components/table_visualization.scss +++ b/src/plugins/vis_type_table/public/components/table_visualization.scss @@ -12,6 +12,15 @@ overflow: auto; @include euiScrollBar; + + // Sticky footer doesn't correct work with inline-flex in Firefox. + // As footer the last element I don't see any reason to use inline-flex for this element. + // Display: flex fixes jumping on hover in Firefox. + // Created issue on EUI (https://github.com/elastic/eui/issues/4729). + // Once addressed, we can remove this local fix. + .euiDataGrid--stickyFooter .euiDataGridFooter { + display: flex; + } } .tbvChart__split { diff --git a/src/plugins/vis_type_timelion/common/types.ts b/src/plugins/vis_type_timelion/common/types.ts index f3f2a74a711a9..8ce4bd8b45f0d 100644 --- a/src/plugins/vis_type_timelion/common/types.ts +++ b/src/plugins/vis_type_timelion/common/types.ts @@ -16,6 +16,7 @@ export interface TimelionFunctionArgsSuggestion { export interface TimelionFunctionArgs { name: string; help?: string; + insertText?: string; multi?: boolean; types: TimelionFunctionArgsTypes[]; suggestions?: TimelionFunctionArgsSuggestion[]; diff --git a/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts b/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts index 7c24eaa5902b4..6c3cd8058627a 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts +++ b/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts @@ -244,10 +244,9 @@ export function getSuggestion( break; case SUGGESTION_TYPE.ARGUMENT_VALUE: - const param = suggestion.name.split(':'); - - if (param.length === 1 || param[1]) { - insertText = `${param.length === 1 ? insertText : param[1]},`; + const defaultText = (suggestion as TimelionFunctionArgs).insertText; + if (defaultText) { + insertText = `${defaultText},`; } command = { diff --git a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts index 0a989858706df..d8ec46eba004f 100644 --- a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts +++ b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts @@ -51,6 +51,7 @@ export function getArgValueSuggestions() { return (await indexPatterns.find(search, size)).map(({ title }) => ({ name: title, + insertText: title, })); }, async metric(partial: string, functionArgs: TimelionExpressionFunction[]) { @@ -81,7 +82,14 @@ export function getArgValueSuggestions() { containsFieldName(valueSplit[1], field) && !indexPatternsUtils.isNestedField(field) ) - .map((field) => ({ name: `${valueSplit[0]}:${field.name}`, help: field.type })); + .map((field) => { + const suggestionValue = field.name.replaceAll(':', '\\:'); + return { + name: `${valueSplit[0]}:${suggestionValue}`, + help: field.type, + insertText: suggestionValue, + }; + }); }, async split(partial: string, functionArgs: TimelionExpressionFunction[]) { const indexPattern = await getIndexPattern(functionArgs); @@ -105,7 +113,7 @@ export function getArgValueSuggestions() { containsFieldName(partial, field) && !indexPatternsUtils.isNestedField(field) ) - .map((field) => ({ name: field.name, help: field.type })); + .map((field) => ({ name: field.name, help: field.type, insertText: field.name })); }, async timefield(partial: string, functionArgs: TimelionExpressionFunction[]) { const indexPattern = await getIndexPattern(functionArgs); @@ -121,7 +129,7 @@ export function getArgValueSuggestions() { containsFieldName(partial, field) && !indexPatternsUtils.isNestedField(field) ) - .map((field) => ({ name: field.name })); + .map((field) => ({ name: field.name, insertText: field.name })); }, }, }; diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js b/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js index 566001ce44182..3ace745604660 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js @@ -123,13 +123,35 @@ describe('es', () => { const emptyScriptedFields = []; test('adds a metric agg for each metric', () => { - config.metric = ['sum:beer', 'avg:bytes', 'percentiles:bytes']; + config.metric = [ + 'sum:beer', + 'avg:bytes', + 'percentiles:bytes', + 'cardinality:\\:sample', + 'sum:\\:beer', + 'percentiles:\\:\\:bytes:1.2,1.3,2.7', + 'percentiles:\\:bytes\\:123:20.0,50.0,100.0', + 'percentiles:a:2', + ]; agg = createDateAgg(config, tlConfig, emptyScriptedFields); expect(agg.time_buckets.aggs['sum(beer)']).toEqual({ sum: { field: 'beer' } }); expect(agg.time_buckets.aggs['avg(bytes)']).toEqual({ avg: { field: 'bytes' } }); expect(agg.time_buckets.aggs['percentiles(bytes)']).toEqual({ percentiles: { field: 'bytes' }, }); + expect(agg.time_buckets.aggs['cardinality(:sample)']).toEqual({ + cardinality: { field: ':sample' }, + }); + expect(agg.time_buckets.aggs['sum(:beer)']).toEqual({ sum: { field: ':beer' } }); + expect(agg.time_buckets.aggs['percentiles(::bytes)']).toEqual({ + percentiles: { field: '::bytes', percents: [1.2, 1.3, 2.7] }, + }); + expect(agg.time_buckets.aggs['percentiles(:bytes:123)']).toEqual({ + percentiles: { field: ':bytes:123', percents: [20.0, 50.0, 100.0] }, + }); + expect(agg.time_buckets.aggs['percentiles(a)']).toEqual({ + percentiles: { field: 'a', percents: [2] }, + }); }); test('adds a scripted metric agg for each scripted metric', () => { @@ -158,6 +180,13 @@ describe('es', () => { expect(typeof agg.time_buckets.aggs.count.bucket_script).toBe('object'); expect(agg.time_buckets.aggs.count.bucket_script.buckets_path).toEqual('_count'); }); + + test('has a special `count` metric with redundant field which use a script', () => { + config.metric = ['count:beer']; + agg = createDateAgg(config, tlConfig, emptyScriptedFields); + expect(typeof agg.time_buckets.aggs.count.bucket_script).toBe('object'); + expect(agg.time_buckets.aggs.count.bucket_script.buckets_path).toEqual('_count'); + }); }); }); @@ -305,10 +334,10 @@ describe('es', () => { describe('config.split', () => { test('adds terms aggs, in order, under the filters agg', () => { - config.split = ['beer:5', 'wine:10']; + config.split = ['beer:5', 'wine:10', ':lemo:nade::15', ':jui:ce:723::45']; const request = fn(config, tlConfig, emptyScriptedFields); - const aggs = request.params.body.aggs.q.aggs; + let aggs = request.params.body.aggs.q.aggs; expect(aggs.beer.meta.type).toEqual('split'); expect(aggs.beer.terms.field).toEqual('beer'); @@ -317,6 +346,18 @@ describe('es', () => { expect(aggs.beer.aggs.wine.meta.type).toEqual('split'); expect(aggs.beer.aggs.wine.terms.field).toEqual('wine'); expect(aggs.beer.aggs.wine.terms.size).toEqual(10); + + aggs = aggs.beer.aggs.wine.aggs; + expect(aggs).toHaveProperty(':lemo:nade:'); + expect(aggs[':lemo:nade:'].meta.type).toEqual('split'); + expect(aggs[':lemo:nade:'].terms.field).toEqual(':lemo:nade:'); + expect(aggs[':lemo:nade:'].terms.size).toEqual(15); + + aggs = aggs[':lemo:nade:'].aggs; + expect(aggs).toHaveProperty(':jui:ce:723:'); + expect(aggs[':jui:ce:723:'].meta.type).toEqual('split'); + expect(aggs[':jui:ce:723:'].terms.field).toEqual(':jui:ce:723:'); + expect(aggs[':jui:ce:723:'].terms.size).toEqual(45); }); test('adds scripted terms aggs, in order, under the filters agg', () => { diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js b/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js index a4aa4f73547e4..a30b197e46067 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js @@ -48,17 +48,17 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) let aggCursor = aggs.q.aggs; - _.each(config.split, function (clause) { - clause = clause.split(':'); - if (clause[0] && clause[1]) { - const termsAgg = buildAggBody(clause[0], scriptedFields); - termsAgg.size = parseInt(clause[1], 10); - aggCursor[clause[0]] = { + (config.split || []).forEach((clause) => { + const [field, arg] = clause.split(/:(\d+$)/); + if (field && arg) { + const termsAgg = buildAggBody(field, scriptedFields); + termsAgg.size = parseInt(arg, 10); + aggCursor[field] = { meta: { type: 'split' }, terms: termsAgg, aggs: {}, }; - aggCursor = aggCursor[clause[0]].aggs; + aggCursor = aggCursor[field].aggs; } else { throw new Error('`split` requires field:limit'); } diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js b/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js index 09ae4d8ef3467..55538fbff4e79 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import _ from 'lodash'; import { buildAggBody } from './agg_body'; -import { search } from '../../../../../../plugins/data/server'; +import { search, METRIC_TYPES } from '../../../../../data/server'; + const { dateHistogramInterval } = search.aggs; export default function createDateAgg(config, tlConfig, scriptedFields) { @@ -29,29 +29,39 @@ export default function createDateAgg(config, tlConfig, scriptedFields) { }; dateAgg.time_buckets.aggs = {}; - _.each(config.metric, function (metric) { - metric = metric.split(':'); - if (metric[0] === 'count') { + (config.metric || []).forEach((metric) => { + const metricBody = {}; + const [metricName, metricArgs] = metric.split(/:(.+)/); + if (metricName === METRIC_TYPES.COUNT) { // This is pretty lame, but its how the "doc_count" metric has to be implemented at the moment // It simplifies the aggregation tree walking code considerably - dateAgg.time_buckets.aggs[metric] = { + metricBody[metricName] = { bucket_script: { buckets_path: '_count', script: { source: '_value', lang: 'expression' }, }, }; - } else if (metric[0] && metric[1]) { - const metricName = metric[0] + '(' + metric[1] + ')'; - dateAgg.time_buckets.aggs[metricName] = {}; - dateAgg.time_buckets.aggs[metricName][metric[0]] = buildAggBody(metric[1], scriptedFields); - if (metric[0] === 'percentiles' && metric[2]) { - let percentList = metric[2].split(','); + } else if (metricName && metricArgs) { + const splittedArgs = metricArgs.split(/(.*[^\\]):/).filter(Boolean); + const field = splittedArgs[0].replace(/\\:/g, ':'); + const percentArgs = splittedArgs[1]; + const metricKey = metricName + '(' + field + ')'; + + metricBody[metricKey] = { [metricName]: buildAggBody(field, scriptedFields) }; + + if (metricName === METRIC_TYPES.PERCENTILES && percentArgs) { + let percentList = percentArgs.split(','); percentList = percentList.map((x) => parseFloat(x)); - dateAgg.time_buckets.aggs[metricName][metric[0]].percents = percentList; + metricBody[metricKey][metricName].percents = percentList; } } else { throw new Error('`metric` requires metric:field or simply count'); } + + dateAgg.time_buckets.aggs = { + ...dateAgg.time_buckets.aggs, + ...metricBody, + }; }); return dateAgg; diff --git a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js index e7a34c6e6596d..c5b3d86f61b5d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js @@ -18,7 +18,6 @@ import { EuiComboBox, EuiRange, EuiIconTip, - EuiText, EuiFormLabel, } from '@elastic/eui'; import { FieldSelect } from './aggs/field_select'; @@ -126,6 +125,9 @@ export const IndexPattern = ({ ({ value }) => model[TIME_RANGE_MODE_KEY] === value ); const isTimeSeries = model.type === PANEL_TYPES.TIMESERIES; + const isDataTimerangeModeInvalid = + selectedTimeRangeOption && + !isTimerangeModeEnabled(selectedTimeRangeOption.value, uiRestrictions); useEffect(() => { updateControlValidity(intervalName, intervalValidation.isValid); @@ -143,13 +145,38 @@ export const IndexPattern = ({ + {' '} + + } + type="questionInCircle" + /> + + } + isInvalid={isDataTimerangeModeInvalid} + error={i18n.translate('visTypeTimeseries.indexPattern.timeRange.error', { + defaultMessage: 'You cannot use "{mode}" with the current index type.', + values: { + mode: selectedTimeRangeOption?.label, + }, })} > - - {i18n.translate('visTypeTimeseries.indexPattern.timeRange.hint', { - defaultMessage: `This setting controls the timespan used for matching documents. - "Entire timerange" will match all the documents selected in the timepicker. - "Last value" will match only the documents for the specified interval from the end of the timerange.`, - })} - )} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index ec6f2a7c21af6..0ac00863d0a73 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -58,8 +58,8 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { if ( indexPatternString && - !isIndexPatternContainsWildcard(indexPatternString) && - (!indexPattern || indexPattern.type === 'rollup') + ((!indexPattern && !isIndexPatternContainsWildcard(indexPatternString)) || + indexPattern?.type === 'rollup') ) { const rollupData = await this.getRollupData(requestContext, indexPatternString); const rollupIndices = getRollupIndices(rollupData); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts index 6d165d3343eaa..1d910dab5a786 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts @@ -105,5 +105,6 @@ export async function getSeriesData( ...handleErrorResponse(panel)(err), }; } + return meta; } } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts index 00d23ee45e6da..075e90762f151 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts @@ -115,5 +115,6 @@ export async function getTableData( ...handleErrorResponse(panel)(err), }; } + return meta; } } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts index b0ccdbba021ed..8f5770500253f 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts @@ -34,6 +34,7 @@ export class VegaBaseView { destroy(): Promise; _$container: any; + _$controls: any; _parser: any; _vegaViewConfig: any; _serviceSettings: VegaViewParams['serviceSettings']; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts index da4c14c77bc98..53337388dc190 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts @@ -36,6 +36,7 @@ describe('vega_map_view/tms_raster_layer', () => { vegaView: ({ initialize: jest.fn(), } as unknown) as View, + vegaControls: 'element', updateVegaView: jest.fn(), }; }); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts index a3efba804b454..8972b80cb99c5 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts @@ -13,12 +13,13 @@ import type { LayerParameters } from './types'; export interface VegaLayerContext { vegaView: View; updateVegaView: (map: Map, view: View) => void; + vegaControls: any; } export function initVegaLayer({ id, map: mapInstance, - context: { vegaView, updateVegaView }, + context: { vegaView, vegaControls, updateVegaView }, }: LayerParameters) { const vegaLayer: CustomLayerInterface = { id, @@ -34,7 +35,7 @@ export function initVegaLayer({ vegaContainer.style.height = mapCanvas.style.height; mapContainer.appendChild(vegaContainer); - vegaView.initialize(vegaContainer); + vegaView.initialize(vegaContainer, vegaControls); }, render() { updateVegaView(mapInstance, vegaView); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts index b1ec79e6b8310..61ae1ce4e5d78 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts @@ -175,6 +175,7 @@ export class VegaMapView extends VegaBaseView { map: mapBoxInstance, context: { vegaView, + vegaControls: this._$controls.get(0), updateVegaView, }, }); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 2b5a611cd946e..48bff8d203ebd 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -113,7 +113,7 @@ export class VisualizeEmbeddableFactory public getDisplayName() { return i18n.translate('visualizations.displayName', { - defaultMessage: 'visualization', + defaultMessage: 'Visualization', }); } diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index e5b1ba73d9d1c..dbcbb864d2316 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -25,7 +25,7 @@ export { getVisSchemas } from './vis_schemas'; /** @public types */ export { VisualizationsSetup, VisualizationsStart }; export { VisGroups } from './vis_types'; -export type { VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types'; +export type { BaseVisType, VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types'; export { SerializedVis, SerializedVisData, VisData } from './vis'; export type VisualizeEmbeddableFactoryContract = PublicContract; export type VisualizeEmbeddableContract = PublicContract; diff --git a/src/plugins/visualizations/public/wizard/dialog_navigation.tsx b/src/plugins/visualizations/public/wizard/dialog_navigation.tsx index 1de177e12f40d..c92514d54166f 100644 --- a/src/plugins/visualizations/public/wizard/dialog_navigation.tsx +++ b/src/plugins/visualizations/public/wizard/dialog_navigation.tsx @@ -24,7 +24,7 @@ function DialogNavigation(props: DialogNavigationProps) { {i18n.translate('visualizations.newVisWizard.goBackLink', { - defaultMessage: 'Go back', + defaultMessage: 'Select a different visualization', })} diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index d36b734f75be2..317f9d1bb363d 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -41,6 +41,8 @@ interface TypeSelectionProps { outsideVisualizeApp?: boolean; stateTransfer?: EmbeddableStateTransfer; originatingApp?: string; + showAggsSelection?: boolean; + selectedVisType?: BaseVisType; } interface TypeSelectionState { @@ -69,8 +71,9 @@ class NewVisModal extends React.Component import('./new_vis_modal')); @@ -29,6 +30,8 @@ export interface ShowNewVisModalParams { originatingApp?: string; outsideVisualizeApp?: boolean; createByValue?: boolean; + showAggsSelection?: boolean; + selectedVisType?: BaseVisType; } /** @@ -41,6 +44,8 @@ export function showNewVisModal({ onClose, originatingApp, outsideVisualizeApp, + showAggsSelection, + selectedVisType, }: ShowNewVisModalParams = {}) { const container = document.createElement('div'); let isClosed = false; @@ -78,6 +83,8 @@ export function showNewVisModal({ usageCollection={getUsageCollector()} application={getApplication()} docLinks={getDocLinks()} + showAggsSelection={showAggsSelection} + selectedVisType={selectedVisType} /> diff --git a/src/setup_node_env/ensure_node_preserve_symlinks.js b/src/setup_node_env/ensure_node_preserve_symlinks.js index 826244c4829fc..3899564203622 100644 --- a/src/setup_node_env/ensure_node_preserve_symlinks.js +++ b/src/setup_node_env/ensure_node_preserve_symlinks.js @@ -99,6 +99,13 @@ return 0; }; + // Since we are using `stdio: inherit`, the child process will receive + // the `SIGINT` and `SIGTERM` from the terminal. + // However, we want the parent process not to exit until the child does. + // Adding the following handlers achieves that. + process.on('SIGINT', function () {}); + process.on('SIGTERM', function () {}); + var spawnResult = cp.spawnSync(nodeArgv[0], nodeArgs.concat(restArgs), { stdio: 'inherit' }); process.exit(getExitCodeFromSpawnResult(spawnResult)); })(); diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index a46a27a5bdf9c..67abe15ea537a 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -23,6 +23,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.loadIfNeeded('logstash_functional'); await kibanaServer.uiSettings.update({ defaultIndex: 'logstash-*', + 'doc_table:legacy': true, }); await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setDefaultAbsoluteRange(); diff --git a/test/api_integration/apis/saved_objects/find.ts b/test/api_integration/apis/saved_objects/find.ts index 28c38ca9e0ded..a4862707e2d0e 100644 --- a/test/api_integration/apis/saved_objects/find.ts +++ b/test/api_integration/apis/saved_objects/find.ts @@ -9,7 +9,6 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; import { SavedObject } from '../../../../src/core/server'; -import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -17,12 +16,6 @@ export default function ({ getService }: FtrProviderContext) { const esDeleteAllIndices = getService('esDeleteAllIndices'); describe('find', () => { - let KIBANA_VERSION: string; - - before(async () => { - KIBANA_VERSION = await getKibanaVersion(getService); - }); - describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -32,33 +25,9 @@ export default function ({ getService }: FtrProviderContext) { .get('/api/saved_objects/_find?type=visualization&fields=title') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzE4LDJd', - attributes: { - title: 'Count of requests', - }, - score: 0, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['default'], - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - updated_at: '2017-09-21T18:51:23.794Z', - }, - ], - }); + expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([ + 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + ]); expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); })); @@ -129,33 +98,12 @@ export default function ({ getService }: FtrProviderContext) { .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=default') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzE4LDJd', - attributes: { - title: 'Count of requests', - }, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['default'], - score: 0, - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - updated_at: '2017-09-21T18:51:23.794Z', - }, - ], - }); + expect( + resp.body.saved_objects.map((so: { id: string; namespaces: string[] }) => ({ + id: so.id, + namespaces: so.namespaces, + })) + ).to.eql([{ id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['default'] }]); expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); })); }); @@ -166,53 +114,15 @@ export default function ({ getService }: FtrProviderContext) { .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=*') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 2, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzE4LDJd', - attributes: { - title: 'Count of requests', - }, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['default'], - score: 0, - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - updated_at: '2017-09-21T18:51:23.794Z', - }, - { - attributes: { - title: 'Count of requests', - }, - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['foo-ns'], - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - score: 0, - type: 'visualization', - updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzIyLDJd', - }, - ], - }); + expect( + resp.body.saved_objects.map((so: { id: string; namespaces: string[] }) => ({ + id: so.id, + namespaces: so.namespaces, + })) + ).to.eql([ + { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['default'] }, + { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['foo-ns'] }, + ]); })); }); @@ -224,42 +134,9 @@ export default function ({ getService }: FtrProviderContext) { ) .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - attributes: { - title: 'Count of requests', - visState: resp.body.saved_objects[0].attributes.visState, - uiStateJSON: '{"spy":{"mode":{"name":null,"fill":false}}}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: - resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta - .searchSourceJSON, - }, - }, - namespaces: ['default'], - score: 0, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - }, - ], - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzE4LDJd', - }, - ], - }); + expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([ + 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + ]); })); it('wrong type should return 400 with Bad Request', async () => @@ -293,6 +170,75 @@ export default function ({ getService }: FtrProviderContext) { })); }); + describe('using aggregations', () => { + it('should return 200 with valid response for a valid aggregation', async () => + await supertest + .get( + `/api/saved_objects/_find?type=visualization&per_page=0&aggs=${encodeURIComponent( + JSON.stringify({ + type_count: { max: { field: 'visualization.attributes.version' } }, + }) + )}` + ) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + aggregations: { + type_count: { + value: 1, + }, + }, + page: 1, + per_page: 0, + saved_objects: [], + total: 1, + }); + })); + + it('should return a 400 when referencing an invalid SO attribute', async () => + await supertest + .get( + `/api/saved_objects/_find?type=visualization&per_page=0&aggs=${encodeURIComponent( + JSON.stringify({ + type_count: { max: { field: 'dashboard.attributes.version' } }, + }) + )}` + ) + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: + 'Invalid aggregation: [type_count.max.field] Invalid attribute path: dashboard.attributes.version: Bad Request', + statusCode: 400, + }); + })); + + it('should return a 400 when using a forbidden aggregation option', async () => + await supertest + .get( + `/api/saved_objects/_find?type=visualization&per_page=0&aggs=${encodeURIComponent( + JSON.stringify({ + type_count: { + max: { + field: 'visualization.attributes.version', + script: 'Bad script is bad', + }, + }, + }) + )}` + ) + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: + 'Invalid aggregation: [type_count.max.script]: definition for this key is missing: Bad Request', + statusCode: 400, + }); + })); + }); + describe('`has_reference` and `has_reference_operator` parameters', () => { before(() => esArchiver.load('saved_objects/references')); after(() => esArchiver.unload('saved_objects/references')); diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 6ab2352ebb05f..8fb3884a5b37b 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -34,44 +34,9 @@ export default function ({ getService }: FtrProviderContext) { .get('/api/kibana/management/saved_objects/_find?type=visualization&fields=title') .expect(200) .then((resp: Response) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzE4LDJd', - attributes: { - title: 'Count of requests', - }, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['default'], - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - score: 0, - updated_at: '2017-09-21T18:51:23.794Z', - meta: { - editUrl: - '/management/kibana/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab', - uiCapabilitiesPath: 'visualize.show', - }, - title: 'Count of requests', - namespaceType: 'single', - }, - }, - ], - }); + expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([ + 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + ]); })); describe('unknown type', () => { diff --git a/test/api_integration/apis/telemetry/telemetry_local.ts b/test/api_integration/apis/telemetry/telemetry_local.ts index 9b92576c84b3a..c14fc658f2768 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/test/api_integration/apis/telemetry/telemetry_local.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import supertestAsPromised from 'supertest-as-promised'; +import { omit } from 'lodash'; import { basicUiCounters } from './__fixtures__/ui_counters'; import { basicUsageCounters } from './__fixtures__/usage_counters'; import type { FtrProviderContext } from '../../ftr_provider_context'; @@ -86,6 +87,35 @@ export default function ({ getService }: FtrProviderContext) { expect(stats.stack_stats.kibana.plugins.csp.strict).to.be(true); expect(stats.stack_stats.kibana.plugins.csp.warnLegacyBrowsers).to.be(true); expect(stats.stack_stats.kibana.plugins.csp.rulesChangedFromDefault).to.be(false); + expect(stats.stack_stats.kibana.plugins.kibana_config_usage).to.be.an('object'); + // non-default kibana configs. Configs set at 'test/api_integration/config.js'. + expect(omit(stats.stack_stats.kibana.plugins.kibana_config_usage, 'server.port')).to.eql({ + 'elasticsearch.username': '[redacted]', + 'elasticsearch.password': '[redacted]', + 'elasticsearch.hosts': '[redacted]', + 'elasticsearch.healthCheck.delay': 3600000, + 'plugins.paths': '[redacted]', + 'logging.json': false, + 'server.xsrf.disableProtection': true, + 'server.compression.referrerWhitelist': '[redacted]', + 'server.maxPayload': 1679958, + 'status.allowAnonymous': true, + 'home.disableWelcomeScreen': true, + 'data.search.aggs.shardDelay.enabled': true, + 'security.showInsecureClusterWarning': false, + 'telemetry.banner': false, + 'telemetry.url': '[redacted]', + 'telemetry.optInStatusUrl': '[redacted]', + 'telemetry.optIn': false, + 'newsfeed.service.urlRoot': '[redacted]', + 'newsfeed.service.pathTemplate': '[redacted]', + 'savedObjects.maxImportPayloadBytes': 10485760, + 'savedObjects.maxImportExportSize': 10001, + 'usageCollection.usageCounters.bufferDuration': 0, + }); + expect(stats.stack_stats.kibana.plugins.kibana_config_usage['server.port']).to.be.a( + 'number' + ); // Testing stack_stats.data expect(stats.stack_stats.data).to.be.an('object'); diff --git a/test/api_integration/apis/telemetry/utils/schema_to_config_schema.ts b/test/api_integration/apis/telemetry/utils/schema_to_config_schema.ts index b45930682e3aa..ec44cec39c29a 100644 --- a/test/api_integration/apis/telemetry/utils/schema_to_config_schema.ts +++ b/test/api_integration/apis/telemetry/utils/schema_to_config_schema.ts @@ -8,8 +8,8 @@ import type { ObjectType, Type } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; -import { get } from 'lodash'; import { set } from '@elastic/safer-lodash-set'; +import { get, merge } from 'lodash'; import type { AllowedSchemaTypes } from 'src/plugins/usage_collection/server'; /** @@ -125,11 +125,19 @@ export function assertTelemetryPayload( stats: unknown ): void { const fullSchema = telemetrySchema.root; + + const mergedPluginsSchema = merge( + {}, + get(fullSchema, 'properties.stack_stats.properties.kibana.properties.plugins'), + telemetrySchema.plugins + ); + set( fullSchema, 'properties.stack_stats.properties.kibana.properties.plugins', - telemetrySchema.plugins + mergedPluginsSchema ); + const ossTelemetryValidationSchema = convertSchemaToConfigSchema(fullSchema); // Run @kbn/config-schema validation to the entire payload diff --git a/test/common/config.js b/test/common/config.js index 46cd07b2ec370..84848347f94cd 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -50,7 +50,7 @@ export default function () { // These are *very* important to have them pointing to staging '--telemetry.url=https://telemetry-staging.elastic.co/xpack/v2/send', '--telemetry.optInStatusUrl=https://telemetry-staging.elastic.co/opt_in_status/v2/send', - `--server.maxPayloadBytes=1679958`, + `--server.maxPayload=1679958`, // newsfeed mock service `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'newsfeed')}`, `--newsfeed.service.urlRoot=${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}`, diff --git a/test/examples/embeddables/adding_children.ts b/test/examples/embeddables/adding_children.ts index 8b59012bf9825..ee06622a33f51 100644 --- a/test/examples/embeddables/adding_children.ts +++ b/test/examples/embeddables/adding_children.ts @@ -13,31 +13,12 @@ import { PluginFunctionalProviderContext } from 'test/plugin_functional/services export default function ({ getService }: PluginFunctionalProviderContext) { const testSubjects = getService('testSubjects'); const flyout = getService('flyout'); - const retry = getService('retry'); - describe('creating and adding children', () => { + describe('adding children', () => { before(async () => { await testSubjects.click('embeddablePanelExample'); }); - it('Can create a new child', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); - - // this seem like an overkill, but clicking this button which opens context menu was flaky - await testSubjects.waitForEnabled('createNew'); - await retry.waitFor('createNew popover opened', async () => { - await testSubjects.click('createNew'); - return await testSubjects.exists('createNew-TODO_EMBEDDABLE'); - }); - await testSubjects.click('createNew-TODO_EMBEDDABLE'); - - await testSubjects.setValue('taskInputField', 'new task'); - await testSubjects.click('createTodoEmbeddable'); - const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); - expect(tasks).to.eql(['Goes out on Wednesdays!', 'new task']); - }); - it('Can add a child backed off a saved object', async () => { await testSubjects.click('embeddablePanelToggleMenuIcon'); await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); @@ -46,7 +27,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { await testSubjects.moveMouseTo('euiFlyoutCloseButton'); await flyout.ensureClosed('dashboardAddPanel'); const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); - expect(tasks).to.eql(['Goes out on Wednesdays!', 'new task', 'Take the garbage out']); + expect(tasks).to.eql(['Goes out on Wednesdays!', 'Take the garbage out']); }); }); } diff --git a/test/examples/embeddables/dashboard.ts b/test/examples/embeddables/dashboard.ts index 7db49d9dfbfcb..70e5ba115c3af 100644 --- a/test/examples/embeddables/dashboard.ts +++ b/test/examples/embeddables/dashboard.ts @@ -117,7 +117,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }); it('saved search', async () => { - await dashboardExpect.savedSearchRowCount(50); + await dashboardExpect.savedSearchRowCount(11); }); }); diff --git a/test/functional/apps/context/_context_navigation.js b/test/functional/apps/context/_context_navigation.js index 56415f38f92fd..7f72d44c50ea0 100644 --- a/test/functional/apps/context/_context_navigation.js +++ b/test/functional/apps/context/_context_navigation.js @@ -19,10 +19,12 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const docTable = getService('docTable'); const PageObjects = getPageObjects(['common', 'context', 'discover', 'timePicker']); + const kibanaServer = getService('kibanaServer'); describe('discover - context - back navigation', function contextSize() { before(async function () { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); await PageObjects.common.navigateToApp('discover'); for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) { await PageObjects.discover.clickFieldListItem(columnName); @@ -30,6 +32,10 @@ export default function ({ getService, getPageObjects }) { } }); + after(async function () { + await kibanaServer.uiSettings.replace({}); + }); + it('should go back after loading', async function () { await retry.waitFor('user navigating to context and returning to discover', async () => { // navigate to the context view diff --git a/test/functional/apps/context/_date_nanos_custom_timestamp.js b/test/functional/apps/context/_date_nanos_custom_timestamp.js index 8860204b058a5..b837c55d821ac 100644 --- a/test/functional/apps/context/_date_nanos_custom_timestamp.js +++ b/test/functional/apps/context/_date_nanos_custom_timestamp.js @@ -27,7 +27,6 @@ export default function ({ getService, getPageObjects }) { await kibanaServer.uiSettings.update({ 'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`, 'context:step': `${TEST_STEP_SIZE}`, - 'discover:searchFieldsFromSource': true, }); }); @@ -35,9 +34,9 @@ export default function ({ getService, getPageObjects }) { await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, '1'); const actualRowsText = await docTable.getRowsText(); const expectedRowsText = [ - 'Oct 21, 2019 @ 08:30:04.828733000 -', - 'Oct 21, 2019 @ 00:30:04.828740000 -', - 'Oct 21, 2019 @ 00:30:04.828723000 -', + 'Oct 21, 2019 @ 08:30:04.828733000', + 'Oct 21, 2019 @ 00:30:04.828740000', + 'Oct 21, 2019 @ 00:30:04.828723000', ]; expect(actualRowsText).to.eql(expectedRowsText); }); diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index 572ee3dedf35a..dc5d56271c7fd 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -30,10 +30,12 @@ export default function ({ getService, getPageObjects }) { const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); describe('context link in discover', () => { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); await PageObjects.common.navigateToApp('discover'); for (const columnName of TEST_COLUMN_NAMES) { @@ -46,7 +48,7 @@ export default function ({ getService, getPageObjects }) { } }); after(async () => { - await PageObjects.timePicker.resetDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.replace({}); }); it('should open the context view with the selected document as anchor', async () => { diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.ts b/test/functional/apps/dashboard/create_and_add_embeddables.ts index 9b8fc4785a671..3de3b2f843f55 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.ts +++ b/test/functional/apps/dashboard/create_and_add_embeddables.ts @@ -35,8 +35,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adds new visualization via the top nav link', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await PageObjects.dashboard.switchToEditMode(); - await dashboardAddPanel.clickCreateNewLink(); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess( @@ -52,9 +52,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adds a new visualization', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); - await dashboardAddPanel.ensureAddPanelIsShowing(); - await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess( @@ -71,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adds a markdown visualization via the quick button', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); - await PageObjects.dashboard.clickMarkdownQuickButton(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visualize.saveVisualizationExpectSuccess( 'visualization from markdown quick button', { redirectToOrigin: true } @@ -84,21 +83,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.waitForRenderComplete(); }); - it('adds an input control visualization via the quick button', async () => { - const originalPanelCount = await PageObjects.dashboard.getPanelCount(); - await PageObjects.dashboard.clickInputControlsQuickButton(); - await PageObjects.visualize.saveVisualizationExpectSuccess( - 'visualization from input control quick button', - { redirectToOrigin: true } - ); - - await retry.try(async () => { - const panelCount = await PageObjects.dashboard.getPanelCount(); - expect(panelCount).to.eql(originalPanelCount + 1); - }); - await PageObjects.dashboard.waitForRenderComplete(); - }); - it('saves the listing page instead of the visualization to the app link', async () => { await PageObjects.header.clickVisualize(true); const currentUrl = await browser.getCurrentUrl(); diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.ts b/test/functional/apps/dashboard/dashboard_filter_bar.ts index cb2b4a1792a47..ad7e4be9b1935 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.ts +++ b/test/functional/apps/dashboard/dashboard_filter_bar.ts @@ -11,6 +11,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { + const dataGrid = getService('dataGrid'); const dashboardExpect = getService('dashboardExpect'); const dashboardAddPanel = getService('dashboardAddPanel'); const testSubjects = getService('testSubjects'); @@ -173,8 +174,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('are added when a cell magnifying glass is clicked', async function () { await dashboardAddPanel.addSavedSearch('Rendering-Test:-saved-search'); await PageObjects.dashboard.waitForRenderComplete(); - await testSubjects.click('docTableCellFilter'); - + const documentCell = await dataGrid.getCellElement(1, 3); + await documentCell.click(); + const expandCellContentButton = await documentCell.findByClassName( + 'euiDataGridRowCell__expandButtonIcon' + ); + await expandCellContentButton.click(); + await testSubjects.click('filterForButton'); const filterCount = await filterBar.getFilterCount(); expect(filterCount).to.equal(1); }); diff --git a/test/functional/apps/dashboard/dashboard_time_picker.ts b/test/functional/apps/dashboard/dashboard_time_picker.ts index ef03320438582..eb7c05079fb44 100644 --- a/test/functional/apps/dashboard/dashboard_time_picker.ts +++ b/test/functional/apps/dashboard/dashboard_time_picker.ts @@ -12,13 +12,13 @@ import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const dashboardExpect = getService('dashboardExpect'); const pieChart = getService('pieChart'); const dashboardVisualizations = getService('dashboardVisualizations'); const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'timePicker']); const browser = getService('browser'); const log = getService('log'); const kibanaServer = getService('kibanaServer'); + const dataGrid = getService('dataGrid'); describe('dashboard time picker', function describeIndexTests() { before(async function () { @@ -49,14 +49,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { name: 'saved search', fields: ['bytes', 'agent'], }); - await dashboardExpect.docTableFieldCount(150); + const initialRows = await dataGrid.getDocTableRows(); + expect(initialRows.length).to.be(11); // Set to time range with no data await PageObjects.timePicker.setAbsoluteRange( 'Jan 1, 2000 @ 00:00:00.000', 'Jan 1, 2000 @ 01:00:00.000' ); - await dashboardExpect.docTableFieldCount(0); + const noResults = await dataGrid.hasNoResults(); + expect(noResults).to.be.ok(); }); it('Timepicker start, end, interval values are set by url', async () => { diff --git a/test/functional/apps/dashboard/dashboard_unsaved_listing.ts b/test/functional/apps/dashboard/dashboard_unsaved_listing.ts index 233d2e91467fe..1cdc4bbff2c53 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_listing.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_listing.ts @@ -25,8 +25,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard unsaved listing', () => { const addSomePanels = async () => { // add an area chart by value - await dashboardAddPanel.clickCreateNewLink(); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationAndReturn(); @@ -132,8 +132,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.switchToEditMode(); // add another panel so we can delete it later - await dashboardAddPanel.clickCreateNewLink(); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess('Wildvis', { diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index e6cc91880010a..fd203cd8c1356 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -41,8 +41,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('shows the unsaved changes badge after adding panels', async () => { await PageObjects.dashboard.switchToEditMode(); // add an area chart by value - await dashboardAddPanel.clickCreateNewLink(); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationAndReturn(); diff --git a/test/functional/apps/dashboard/edit_embeddable_redirects.ts b/test/functional/apps/dashboard/edit_embeddable_redirects.ts index 8b7b98a59aa12..be540e18a503f 100644 --- a/test/functional/apps/dashboard/edit_embeddable_redirects.ts +++ b/test/functional/apps/dashboard/edit_embeddable_redirects.ts @@ -13,10 +13,9 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); const esArchiver = getService('esArchiver'); - const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); - const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardAddPanel = getService('dashboardAddPanel'); describe('edit embeddable redirects', () => { before(async () => { @@ -88,10 +87,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const newTitle = 'test create panel originatingApp'; await PageObjects.dashboard.loadSavedDashboard('few panels'); await PageObjects.dashboard.switchToEditMode(); - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visualize.saveVisualizationExpectSuccess(newTitle, { saveAsNew: true, redirectToOrigin: false, diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js index ce32f53587e74..b2f21aefcf79c 100644 --- a/test/functional/apps/dashboard/edit_visualizations.js +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -14,13 +14,14 @@ export default function ({ getService, getPageObjects }) { const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); const originalMarkdownText = 'Original markdown text'; const modifiedMarkdownText = 'Modified markdown text'; const createMarkdownVis = async (title) => { - await PageObjects.dashboard.clickMarkdownQuickButton(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText); await PageObjects.visEditor.clickGo(); if (title) { diff --git a/test/functional/apps/dashboard/embeddable_rendering.ts b/test/functional/apps/dashboard/embeddable_rendering.ts index 2a9551786de6e..11807831dc352 100644 --- a/test/functional/apps/dashboard/embeddable_rendering.ts +++ b/test/functional/apps/dashboard/embeddable_rendering.ts @@ -64,7 +64,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // TODO add test for 'scripted filter and query' viz // TODO add test for 'animal weight linked to search' viz // TODO add test for the last vega viz - await dashboardExpect.savedSearchRowCount(50); + await dashboardExpect.savedSearchRowCount(11); }; const expectNoDataRenders = async () => { diff --git a/test/functional/apps/dashboard/empty_dashboard.ts b/test/functional/apps/dashboard/empty_dashboard.ts index c096d90aa3595..2cfa6d73dcb72 100644 --- a/test/functional/apps/dashboard/empty_dashboard.ts +++ b/test/functional/apps/dashboard/empty_dashboard.ts @@ -41,15 +41,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should open add panel when add button is clicked', async () => { - await testSubjects.click('dashboardAddPanelButton'); + await dashboardAddPanel.clickOpenAddPanel(); const isAddPanelOpen = await dashboardAddPanel.isAddPanelOpen(); expect(isAddPanelOpen).to.be(true); await testSubjects.click('euiFlyoutCloseButton'); }); it('should add new visualization from dashboard', async () => { - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); await dashboardVisualizations.createAndAddMarkdown({ name: 'Dashboard Test Markdown', markdown: 'Markdown text', @@ -57,5 +55,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.waitForRenderComplete(); await dashboardExpect.markdownWithValuesExists(['Markdown text']); }); + + it('should open editor menu when editor button is clicked', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await testSubjects.existOrFail('dashboardEditorContextMenu'); + }); }); } diff --git a/test/functional/apps/dashboard/saved_search_embeddable.ts b/test/functional/apps/dashboard/saved_search_embeddable.ts index 71f19b23da9dd..bea5c7d749162 100644 --- a/test/functional/apps/dashboard/saved_search_embeddable.ts +++ b/test/functional/apps/dashboard/saved_search_embeddable.ts @@ -45,7 +45,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const marks = $('mark') .toArray() .map((mark) => $(mark).text()); - expect(marks.length).to.be(50); + expect(marks.length).to.be(11); }); it('removing a filter removes highlights', async function () { diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index c5c7daab27ff1..99a78ebd069c5 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -113,10 +113,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('when a new vis is added', async function () { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); - - await dashboardAddPanel.ensureAddPanelIsShowing(); - await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess('new viz panel', { diff --git a/test/functional/apps/discover/_data_grid_doc_table.ts b/test/functional/apps/discover/_data_grid_doc_table.ts index 5499f0250eb73..f8406f4c8a8a9 100644 --- a/test/functional/apps/discover/_data_grid_doc_table.ts +++ b/test/functional/apps/discover/_data_grid_doc_table.ts @@ -22,6 +22,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { defaultIndex: 'logstash-*', 'doc_table:legacy': false, }; + const testSubjects = getService('testSubjects'); describe('discover data grid doc table', function describeIndexTests() { before(async function () { @@ -102,6 +103,31 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.closeFlyout(); }); }); + + it('should show allow adding columns from the detail panel', async function () { + await retry.try(async function () { + await dataGrid.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + + // add columns + const fields = ['_id', '_index', 'agent']; + for (const field of fields) { + await testSubjects.click(`toggleColumnButton_${field}`); + } + + const headerWithFields = await dataGrid.getHeaderFields(); + expect(headerWithFields.join(' ')).to.contain(fields.join(' ')); + + // remove columns + for (const field of fields) { + await testSubjects.click(`toggleColumnButton_${field}`); + } + + const headerWithoutFields = await dataGrid.getHeaderFields(); + expect(headerWithoutFields.join(' ')).not.to.contain(fields.join(' ')); + + await dataGrid.closeFlyout(); + }); + }); }); describe('add and remove columns', function () { diff --git a/test/functional/apps/discover/_data_grid_field_data.ts b/test/functional/apps/discover/_data_grid_field_data.ts index f41a98e2f3364..62c27c8d50dc4 100644 --- a/test/functional/apps/discover/_data_grid_field_data.ts +++ b/test/functional/apps/discover/_data_grid_field_data.ts @@ -41,9 +41,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('the search term should be highlighted in the field data', async function () { // marks is the style that highlights the text in yellow + await PageObjects.discover.clickFieldListItemAdd('extension'); const marks = await PageObjects.discover.getMarks(); - expect(marks.length).to.be(50); + expect(marks.length).to.be.greaterThan(0); expect(marks.indexOf('php')).to.be(0); + await PageObjects.discover.clickFieldListItemRemove('extension'); }); it('search type:apache should show the correct hit count', async function () { diff --git a/test/functional/apps/discover/_date_nanos_mixed.ts b/test/functional/apps/discover/_date_nanos_mixed.ts index 35439ef1e8eb0..47c3a19c06986 100644 --- a/test/functional/apps/discover/_date_nanos_mixed.ts +++ b/test/functional/apps/discover/_date_nanos_mixed.ts @@ -33,14 +33,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('shows a list of records of indices with date & date_nanos fields in the right order', async function () { - const rowData1 = await PageObjects.discover.getDocTableIndex(1); - expect(rowData1.startsWith('Jan 1, 2019 @ 12:10:30.124000000')).to.be.ok(); - const rowData2 = await PageObjects.discover.getDocTableIndex(3); - expect(rowData2.startsWith('Jan 1, 2019 @ 12:10:30.123498765')).to.be.ok(); - const rowData3 = await PageObjects.discover.getDocTableIndex(5); - expect(rowData3.startsWith('Jan 1, 2019 @ 12:10:30.123456789')).to.be.ok(); - const rowData4 = await PageObjects.discover.getDocTableIndex(7); - expect(rowData4.startsWith('Jan 1, 2019 @ 12:10:30.123000000')).to.be.ok(); + const rowData1 = await PageObjects.discover.getDocTableField(1); + expect(rowData1).to.be('Jan 1, 2019 @ 12:10:30.124000000'); + const rowData2 = await PageObjects.discover.getDocTableField(2); + expect(rowData2).to.be('Jan 1, 2019 @ 12:10:30.123498765'); + const rowData3 = await PageObjects.discover.getDocTableField(3); + expect(rowData3).to.be('Jan 1, 2019 @ 12:10:30.123456789'); + const rowData4 = await PageObjects.discover.getDocTableField(4); + expect(rowData4).to.be('Jan 1, 2019 @ 12:10:30.123000000'); }); }); } diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 0c12f32f6e717..ab53eca0cedf5 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -52,7 +52,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(time.end).to.be(PageObjects.timePicker.defaultEndTime); const rowData = await PageObjects.discover.getDocTableIndex(1); log.debug('check the newest doc timestamp in UTC (check diff timezone in last test)'); - expect(rowData.startsWith('Sep 22, 2015 @ 23:50:13.253')).to.be.ok(); + expect(rowData).to.contain('Sep 22, 2015 @ 23:50:13.253'); }); it('save query should show toast message and display query name', async function () { @@ -99,11 +99,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const time = await PageObjects.timePicker.getTimeConfig(); expect(time.start).to.be('Sep 21, 2015 @ 09:00:00.000'); expect(time.end).to.be('Sep 21, 2015 @ 12:00:00.000'); - await retry.waitFor('doc table to contain the right search result', async () => { - const rowData = await PageObjects.discover.getDocTableField(1); - log.debug(`The first timestamp value in doc table: ${rowData}`); - return rowData.includes('Sep 21, 2015 @ 11:59:22.316'); - }); + await retry.waitForWithTimeout( + 'doc table to contain the right search result', + 1000, + async () => { + const rowData = await PageObjects.discover.getDocTableField(1); + log.debug(`The first timestamp value in doc table: ${rowData}`); + return rowData.includes('Sep 21, 2015 @ 11:59:22.316'); + } + ); }); it('should modify the time range when the histogram is brushed', async function () { @@ -304,7 +308,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.clickFieldListItemAdd('_score'); - await PageObjects.discover.clickFieldSort('_score'); + await PageObjects.discover.clickFieldSort('_score', 'Sort Low-High'); const currentUrlWithScore = await browser.getCurrentUrl(); expect(currentUrlWithScore).to.contain('_score'); await PageObjects.discover.clickFieldListItemAdd('_score'); @@ -315,7 +319,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.clickFieldListItemAdd('referer'); - await PageObjects.discover.clickFieldSort('referer'); + await PageObjects.discover.clickFieldSort('referer', 'Sort A-Z'); expect(await PageObjects.discover.getDocHeader()).to.have.string('Referer custom'); expect(await PageObjects.discover.getAllFieldNames()).to.contain('Referer custom'); const url = await browser.getCurrentUrl(); diff --git a/test/functional/apps/discover/_doc_navigation.ts b/test/functional/apps/discover/_doc_navigation.ts index e783d159cb261..90d3c4eca423a 100644 --- a/test/functional/apps/discover/_doc_navigation.ts +++ b/test/functional/apps/discover/_doc_navigation.ts @@ -17,12 +17,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'context']); const esArchiver = getService('esArchiver'); const retry = getService('retry'); + const kibanaServer = getService('kibanaServer'); describe('doc link in discover', function contextSize() { - beforeEach(async function () { + before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.loadIfNeeded('discover'); await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update({ + 'doc_table:legacy': true, + 'discover:searchFieldsFromSource': true, + }); + }); + after(async () => { + await kibanaServer.uiSettings.replace({}); + }); + + beforeEach(async function () { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.waitForDocTableLoadingComplete(); }); diff --git a/test/functional/apps/discover/_doc_table.ts b/test/functional/apps/discover/_doc_table.ts index edcb002000183..1fd26b561195e 100644 --- a/test/functional/apps/discover/_doc_table.ts +++ b/test/functional/apps/discover/_doc_table.ts @@ -16,13 +16,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const docTable = getService('docTable'); const queryBar = getService('queryBar'); + const find = getService('find'); const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); const defaultSettings = { defaultIndex: 'logstash-*', }; + const testSubjects = getService('testSubjects'); describe('discover doc table', function describeIndexTests() { - const defaultRowsLimit = 50; const rowsHardLimit = 500; before(async function () { @@ -37,10 +38,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); }); - it('should show the first 50 rows by default', async function () { + it('should show records by default', async function () { // with the default range the number of hits is ~14000 const rows = await PageObjects.discover.getDocTableRows(); - expect(rows.length).to.be(defaultRowsLimit); + expect(rows.length).to.be.greaterThan(0); }); it('should refresh the table content when changing time window', async function () { @@ -57,113 +58,130 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - it(`should load up to ${rowsHardLimit} rows when scrolling at the end of the table`, async function () { - const initialRows = await PageObjects.discover.getDocTableRows(); - // click the Skip to the end of the table - await PageObjects.discover.skipToEndOfDocTable(); - // now count the rows - const finalRows = await PageObjects.discover.getDocTableRows(); - expect(finalRows.length).to.be.above(initialRows.length); - expect(finalRows.length).to.be(rowsHardLimit); - await PageObjects.discover.backToTop(); - }); - - it('should go the end of the table when using the accessible Skip button', async function () { - // click the Skip to the end of the table - await PageObjects.discover.skipToEndOfDocTable(); - // now check the footer text content - const footer = await PageObjects.discover.getDocTableFooter(); - log.debug(await footer.getVisibleText()); - expect(await footer.getVisibleText()).to.have.string(rowsHardLimit); - await PageObjects.discover.backToTop(); - }); + describe('legacy', async function () { + before(async () => { + await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + after(async () => { + await kibanaServer.uiSettings.replace({}); + }); + it(`should load up to ${rowsHardLimit} rows when scrolling at the end of the table`, async function () { + const initialRows = await testSubjects.findAll('docTableRow'); + // click the Skip to the end of the table + await PageObjects.discover.skipToEndOfDocTable(); + // now count the rows + const finalRows = await testSubjects.findAll('docTableRow'); + expect(finalRows.length).to.be.above(initialRows.length); + expect(finalRows.length).to.be(rowsHardLimit); + await PageObjects.discover.backToTop(); + }); - describe('expand a document row', function () { - const rowToInspect = 1; - beforeEach(async function () { - // close the toggle if open - const details = await docTable.getDetailsRows(); - if (details.length) { - await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); - } + it('should go the end of the table when using the accessible Skip button', async function () { + // click the Skip to the end of the table + await PageObjects.discover.skipToEndOfDocTable(); + // now check the footer text content + const footer = await PageObjects.discover.getDocTableFooter(); + log.debug(await footer.getVisibleText()); + expect(await footer.getVisibleText()).to.have.string(rowsHardLimit); + await PageObjects.discover.backToTop(); }); - it('should expand the detail row when the toggle arrow is clicked', async function () { - await retry.try(async function () { - await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); - const detailsEl = await docTable.getDetailsRows(); - const defaultMessageEl = await detailsEl[0].findByTestSubject('docTableRowDetailsTitle'); - expect(defaultMessageEl).to.be.ok(); + describe('expand a document row', function () { + const rowToInspect = 1; + beforeEach(async function () { + // close the toggle if open + const details = await docTable.getDetailsRows(); + if (details.length) { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + } }); - }); - it('should show the detail panel actions', async function () { - await retry.try(async function () { - await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); - // const detailsEl = await PageObjects.discover.getDocTableRowDetails(rowToInspect); - const [surroundingActionEl, singleActionEl] = await docTable.getRowActions({ - isAnchorRow: false, - rowIndex: rowToInspect - 1, + it('should expand the detail row when the toggle arrow is clicked', async function () { + await retry.try(async function () { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + const detailsEl = await docTable.getDetailsRows(); + const defaultMessageEl = await detailsEl[0].findByTestSubject( + 'docTableRowDetailsTitle' + ); + expect(defaultMessageEl).to.be.ok(); + }); + }); + + it('should show the detail panel actions', async function () { + await retry.try(async function () { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + // const detailsEl = await PageObjects.discover.getDocTableRowDetails(rowToInspect); + const [surroundingActionEl, singleActionEl] = await docTable.getRowActions({ + isAnchorRow: false, + rowIndex: rowToInspect - 1, + }); + expect(surroundingActionEl).to.be.ok(); + expect(singleActionEl).to.be.ok(); + // TODO: test something more meaninful here? }); - expect(surroundingActionEl).to.be.ok(); - expect(singleActionEl).to.be.ok(); - // TODO: test something more meaninful here? }); - }); - it('should not close the detail panel actions when data is re-requested', async function () { - await retry.try(async function () { - const nrOfFetches = await PageObjects.discover.getNrOfFetches(); - await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); - const detailsEl = await docTable.getDetailsRows(); - const defaultMessageEl = await detailsEl[0].findByTestSubject('docTableRowDetailsTitle'); - expect(defaultMessageEl).to.be.ok(); - await queryBar.submitQuery(); - const nrOfFetchesResubmit = await PageObjects.discover.getNrOfFetches(); - expect(nrOfFetchesResubmit).to.be.above(nrOfFetches); - const defaultMessageElResubmit = await detailsEl[0].findByTestSubject( - 'docTableRowDetailsTitle' - ); - - expect(defaultMessageElResubmit).to.be.ok(); + it('should not close the detail panel actions when data is re-requested', async function () { + await retry.try(async function () { + const nrOfFetches = await PageObjects.discover.getNrOfFetches(); + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + const detailsEl = await docTable.getDetailsRows(); + const defaultMessageEl = await detailsEl[0].findByTestSubject( + 'docTableRowDetailsTitle' + ); + expect(defaultMessageEl).to.be.ok(); + await queryBar.submitQuery(); + const nrOfFetchesResubmit = await PageObjects.discover.getNrOfFetches(); + expect(nrOfFetchesResubmit).to.be.above(nrOfFetches); + const defaultMessageElResubmit = await detailsEl[0].findByTestSubject( + 'docTableRowDetailsTitle' + ); + + expect(defaultMessageElResubmit).to.be.ok(); + }); }); }); - }); - describe('add and remove columns', function () { - const extraColumns = ['phpmemory', 'ip']; + describe('add and remove columns', function () { + const extraColumns = ['phpmemory', 'ip']; - afterEach(async function () { - for (const column of extraColumns) { - await PageObjects.discover.clickFieldListItemRemove(column); - await PageObjects.header.waitUntilLoadingHasFinished(); - } - }); + afterEach(async function () { + for (const column of extraColumns) { + await PageObjects.discover.clickFieldListItemRemove(column); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + }); - it('should add more columns to the table', async function () { - for (const column of extraColumns) { - await PageObjects.discover.clearFieldSearchInput(); - await PageObjects.discover.findFieldByName(column); - await PageObjects.discover.clickFieldListItemAdd(column); - await PageObjects.header.waitUntilLoadingHasFinished(); - // test the header now - expect(await PageObjects.discover.getDocHeader()).to.have.string(column); - } - }); + it('should add more columns to the table', async function () { + for (const column of extraColumns) { + await PageObjects.discover.clearFieldSearchInput(); + await PageObjects.discover.findFieldByName(column); + await PageObjects.discover.clickFieldListItemAdd(column); + await PageObjects.header.waitUntilLoadingHasFinished(); + // test the header now + const docHeader = await find.byCssSelector('thead > tr:nth-child(1)'); + const docHeaderText = await docHeader.getVisibleText(); + expect(docHeaderText).to.have.string(column); + } + }); - it('should remove columns from the table', async function () { - for (const column of extraColumns) { - await PageObjects.discover.clearFieldSearchInput(); - await PageObjects.discover.findFieldByName(column); - log.debug(`add a ${column} column`); - await PageObjects.discover.clickFieldListItemAdd(column); + it('should remove columns from the table', async function () { + for (const column of extraColumns) { + await PageObjects.discover.clearFieldSearchInput(); + await PageObjects.discover.findFieldByName(column); + log.debug(`add a ${column} column`); + await PageObjects.discover.clickFieldListItemAdd(column); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + // remove the second column + await PageObjects.discover.clickFieldListItemAdd(extraColumns[1]); await PageObjects.header.waitUntilLoadingHasFinished(); - } - // remove the second column - await PageObjects.discover.clickFieldListItemAdd(extraColumns[1]); - await PageObjects.header.waitUntilLoadingHasFinished(); - // test that the second column is no longer there - expect(await PageObjects.discover.getDocHeader()).to.not.have.string(extraColumns[1]); + // test that the second column is no longer there + const docHeader = await find.byCssSelector('thead > tr:nth-child(1)'); + expect(await docHeader.getVisibleText()).to.not.have.string(extraColumns[1]); + }); }); }); }); diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts index 3583a8b12c415..492925cf6b2df 100644 --- a/test/functional/apps/discover/_field_data.ts +++ b/test/functional/apps/discover/_field_data.ts @@ -18,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const queryBar = getService('queryBar'); const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']); + const find = getService('find'); describe('discover tab', function describeIndexTests() { this.tags('includeFirefox'); @@ -44,9 +45,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('the search term should be highlighted in the field data', async function () { // marks is the style that highlights the text in yellow + await queryBar.setQuery('php'); + await queryBar.submitQuery(); + await PageObjects.discover.clickFieldListItemAdd('extension'); const marks = await PageObjects.discover.getMarks(); - expect(marks.length).to.be(50); + expect(marks.length).to.be.greaterThan(0); expect(marks.indexOf('php')).to.be(0); + await PageObjects.discover.clickFieldListItemRemove('extension'); }); it('search type:apache should show the correct hit count', async function () { @@ -59,27 +64,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it('doc view should show Time and _source columns', async function () { - const expectedHeader = 'Time _source'; - const Docheader = await PageObjects.discover.getDocHeader(); - expect(Docheader).to.be(expectedHeader); - }); - - it('doc view should sort ascending', async function () { - const expectedTimeStamp = 'Sep 20, 2015 @ 00:00:00.000'; - await PageObjects.discover.clickDocSortDown(); - - // we don't technically need this sleep here because the tryForTime will retry and the - // results will match on the 2nd or 3rd attempt, but that debug output is huge in this - // case and it can be avoided with just a few seconds sleep. - await PageObjects.common.sleep(2000); - await retry.try(async function tryingForTime() { - const rowData = await PageObjects.discover.getDocTableIndex(1); - - expect(rowData.startsWith(expectedTimeStamp)).to.be.ok(); - }); - }); - it('a bad syntax query should show an error message', async function () { const expectedError = 'Expected ":", "<", "<=", ">", ">=", AND, OR, end of input, ' + @@ -102,15 +86,47 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { useActualUrl: true } ); await retry.try(async function tryingForTime() { - expect(await PageObjects.discover.getDocHeader()).to.be('Time relatedContent'); + expect(await PageObjects.discover.getDocHeader()).to.contain('relatedContent'); }); - const field = await PageObjects.discover.getDocTableField(1, 1); + const field = await PageObjects.discover.getDocTableField(1, 3); expect(field).to.include.string('"og:description":'); const marks = await PageObjects.discover.getMarks(); expect(marks.length).to.be(0); }); + + describe('legacy table tests', async function () { + before(async function () { + await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); + await PageObjects.common.navigateToApp('discover'); + }); + + after(async function () { + await kibanaServer.uiSettings.replace({}); + }); + it('doc view should show Time and _source columns', async function () { + const expectedHeader = 'Time _source'; + const docHeader = await find.byCssSelector('thead > tr:nth-child(1)'); + const docHeaderText = await docHeader.getVisibleText(); + expect(docHeaderText).to.be(expectedHeader); + }); + + it('doc view should sort ascending', async function () { + const expectedTimeStamp = 'Sep 20, 2015 @ 00:00:00.000'; + await find.clickByCssSelector('.fa-sort-down'); + + // we don't technically need this sleep here because the tryForTime will retry and the + // results will match on the 2nd or 3rd attempt, but that debug output is huge in this + // case and it can be avoided with just a few seconds sleep. + await PageObjects.common.sleep(2000); + await retry.try(async function tryingForTime() { + const row = await find.byCssSelector(`tr.kbnDocTable__row:nth-child(1)`); + const rowData = await row.getVisibleText(); + expect(rowData.startsWith(expectedTimeStamp)).to.be.ok(); + }); + }); + }); }); }); } diff --git a/test/functional/apps/discover/_field_data_with_fields_api.ts b/test/functional/apps/discover/_field_data_with_fields_api.ts index 168f718c38602..c270531195011 100644 --- a/test/functional/apps/discover/_field_data_with_fields_api.ts +++ b/test/functional/apps/discover/_field_data_with_fields_api.ts @@ -18,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const queryBar = getService('queryBar'); const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']); + const find = getService('find'); describe('discover tab with new fields API', function describeIndexTests() { this.tags('includeFirefox'); @@ -44,9 +45,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('the search term should be highlighted in the field data', async function () { // marks is the style that highlights the text in yellow + await PageObjects.discover.clickFieldListItemAdd('extension'); const marks = await PageObjects.discover.getMarks(); - expect(marks.length).to.be(100); + expect(marks.length).to.be.greaterThan(0); expect(marks.indexOf('php')).to.be(0); + await PageObjects.discover.clickFieldListItemRemove('extension'); }); it('search type:apache should show the correct hit count', async function () { @@ -60,24 +63,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('doc view should show Time and Document columns', async function () { - const expectedHeader = 'Time Document'; const Docheader = await PageObjects.discover.getDocHeader(); - expect(Docheader).to.be(expectedHeader); - }); - - it('doc view should sort ascending', async function () { - const expectedTimeStamp = 'Sep 20, 2015 @ 00:00:00.000'; - await PageObjects.discover.clickDocSortDown(); - - // we don't technically need this sleep here because the tryForTime will retry and the - // results will match on the 2nd or 3rd attempt, but that debug output is huge in this - // case and it can be avoided with just a few seconds sleep. - await PageObjects.common.sleep(2000); - await retry.try(async function tryingForTime() { - const rowData = await PageObjects.discover.getDocTableIndex(1); - - expect(rowData.startsWith(expectedTimeStamp)).to.be.ok(); - }); + expect(Docheader).to.contain('Time'); + expect(Docheader).to.contain('Document'); }); it('a bad syntax query should show an error message', async function () { @@ -102,15 +90,42 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { useActualUrl: true } ); await retry.try(async function tryingForTime() { - expect(await PageObjects.discover.getDocHeader()).to.be('Time relatedContent'); + expect(await PageObjects.discover.getDocHeader()).to.contain('relatedContent'); }); - const field = await PageObjects.discover.getDocTableField(1, 1); - expect(field).to.include.string('relatedContent.url:'); + const field = await PageObjects.discover.getDocTableField(1, 3); + expect(field).to.include.string('relatedContent.url'); const marks = await PageObjects.discover.getMarks(); - expect(marks.length).to.be(172); - expect(marks.indexOf('election')).to.be(0); + expect(marks.length).to.be.above(0); + expect(marks).to.contain('election'); + }); + + describe('legacy table tests', async function () { + before(async function () { + await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); + await PageObjects.common.navigateToApp('discover'); + }); + + after(async function () { + await kibanaServer.uiSettings.replace({}); + }); + + it('doc view should sort ascending', async function () { + const expectedTimeStamp = 'Sep 20, 2015 @ 00:00:00.000'; + await find.clickByCssSelector('.fa-sort-down'); + + // we don't technically need this sleep here because the tryForTime will retry and the + // results will match on the 2nd or 3rd attempt, but that debug output is huge in this + // case and it can be avoided with just a few seconds sleep. + await PageObjects.common.sleep(2000); + await retry.try(async function tryingForTime() { + const row = await find.byCssSelector(`tr.kbnDocTable__row:nth-child(1)`); + const rowData = await row.getVisibleText(); + + expect(rowData.startsWith(expectedTimeStamp)).to.be.ok(); + }); + }); }); }); }); diff --git a/test/functional/apps/discover/_large_string.ts b/test/functional/apps/discover/_large_string.ts index 3f9a5ab264c7a..fcc36d11a1eb9 100644 --- a/test/functional/apps/discover/_large_string.ts +++ b/test/functional/apps/discover/_large_string.ts @@ -29,7 +29,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('verify the large string book present', async function () { const ExpectedDoc = - 'mybook:Project Gutenberg EBook of Hamlet, by William Shakespeare' + + 'mybookProject Gutenberg EBook of Hamlet, by William Shakespeare' + ' This eBook is for the use of anyone anywhere in the United States' + ' and most other parts of the world at no cost and with almost no restrictions whatsoever.' + ' You may copy it, give it away or re-use it under the terms of the' + diff --git a/test/functional/apps/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts index 7df697a2e7a3a..9add5323db814 100644 --- a/test/functional/apps/discover/_runtime_fields_editor.ts +++ b/test/functional/apps/discover/_runtime_fields_editor.ts @@ -12,7 +12,7 @@ import { FtrProviderContext } from './ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); - const docTable = getService('docTable'); + const dataGrid = getService('dataGrid'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); const esArchiver = getService('esArchiver'); @@ -103,15 +103,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('doc view includes runtime fields', async function () { // navigate to doc view - await docTable.clickRowToggle({ rowIndex: 0 }); + await dataGrid.clickRowToggle(); // click the open action await retry.try(async () => { - const rowActions = await docTable.getRowActions({ rowIndex: 0 }); + const rowActions = await dataGrid.getRowActions({ rowIndex: 0 }); if (!rowActions.length) { throw new Error('row actions empty, trying again'); } - await rowActions[1].click(); + await rowActions[0].click(); }); const hasDocHit = await testSubjects.exists('doc-hit'); diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 1d65b9a68bd4d..a7374c8128630 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -26,8 +26,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const testSubjects = getService('testSubjects'); - // Failing: See https://github.com/elastic/kibana/issues/89477 - describe.skip('saved queries saved objects', function describeIndexTests() { + describe('saved queries saved objects', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); await esArchiver.load('discover'); @@ -134,10 +133,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse'); }); - it('does not allow saving a query with leading or trailing whitespace in the name', async () => { - await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse '); - }); - it('resets any changes to a loaded query on reloading the same saved query', async () => { await savedQueryManagementComponent.loadSavedQuery('OkResponse'); await queryBar.setQuery('response:503'); diff --git a/test/functional/apps/discover/_shared_links.ts b/test/functional/apps/discover/_shared_links.ts index 2893102367b04..9522b665dd649 100644 --- a/test/functional/apps/discover/_shared_links.ts +++ b/test/functional/apps/discover/_shared_links.ts @@ -19,6 +19,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const toasts = getService('toasts'); const deployment = getService('deployment'); + const dataGrid = getService('dataGrid'); describe('shared links', function describeIndexTests() { let baseUrl: string; @@ -110,6 +111,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const actualUrl = await PageObjects.share.getSharedUrl(); expect(actualUrl).to.be(expectedUrl); }); + + it('should load snapshot URL with empty sort param correctly', async function () { + const expectedUrl = + baseUrl + + '/app/discover?_t=1453775307251#' + + '/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time' + + ":(from:'2015-09-19T06:31:44.000Z',to:'2015-09" + + "-23T18:31:44.000Z'))&_a=(columns:!(),filters:!(),index:'logstash-" + + "*',interval:auto,query:(language:kuery,query:'')" + + ',sort:!())'; + await browser.navigateTo(expectedUrl); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await retry.waitFor('url to contain default sorting', async () => { + // url fallback default sort should have been pushed to URL + const url = await browser.getCurrentUrl(); + return url.includes('sort:!(!(%27@timestamp%27,desc))'); + }); + + const row = await dataGrid.getRow({ rowIndex: 0 }); + const firstRowText = await Promise.all( + row.map(async (cell) => await cell.getVisibleText()) + ); + + // sorting requested by ES should be correct + expect(firstRowText).to.contain('Sep 22, 2015 @ 23:50:13.253'); + }); }); }); diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 12669bafcd780..5a4bdfeb6b3e8 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -101,7 +101,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.debug('Checking area, bar and heatmap charts rendered'); await dashboardExpect.seriesElementCount(15); log.debug('Checking saved searches rendered'); - await dashboardExpect.savedSearchRowCount(50); + await dashboardExpect.savedSearchRowCount(11); log.debug('Checking input controls rendered'); await dashboardExpect.inputControlItemCount(3); log.debug('Checking tag cloud rendered'); diff --git a/test/functional/apps/management/_import_objects.ts b/test/functional/apps/management/_import_objects.ts index cb4d46f02f56b..d9eb945be7777 100644 --- a/test/functional/apps/management/_import_objects.ts +++ b/test/functional/apps/management/_import_objects.ts @@ -12,6 +12,7 @@ import { keyBy } from 'lodash'; import { FtrProviderContext } from '../../ftr_provider_context'; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + function uniq(input: T[]): T[] { return [...new Set(input)]; } @@ -23,9 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const log = getService('log'); - // FLAKY: https://github.com/elastic/kibana/issues/95660 - // FLAKY: https://github.com/elastic/kibana/issues/95706 - describe.skip('import objects', function describeIndexTests() { + describe('import objects', function describeIndexTests() { describe('.ndjson file', () => { beforeEach(async function () { await esArchiver.load('management'); @@ -314,7 +313,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // but as the initial popin can take a few ms before fading, we need to wait a little // to avoid clicking twice on the same modal. await delay(1000); - await PageObjects.common.clickConfirmOnModal(false); + await PageObjects.common.clickConfirmOnModal(true); const isSuccessful = await testSubjects.exists('importSavedObjectsSuccess'); expect(isSuccessful).to.be(true); @@ -335,7 +334,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // but as the initial popin can take a few ms before fading, we need to wait a little // to avoid clicking twice on the same modal. await delay(1000); - await PageObjects.common.clickConfirmOnModal(false); + await PageObjects.common.clickConfirmOnModal(true); const isSuccessful = await testSubjects.exists('importSavedObjectsSuccess'); expect(isSuccessful).to.be(true); diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 13af3206a166d..fdbc419c16241 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -50,7 +50,7 @@ export default function ({ getService, getPageObjects }) { await esArchiver.load('discover'); // delete .kibana index and then wait for Kibana to re-create it await kibanaServer.uiSettings.replace({}); - await kibanaServer.uiSettings.update({}); + await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); }); after(async function afterAll() { @@ -149,7 +149,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\n18'); }); }); @@ -163,14 +163,14 @@ export default function ({ getService, getPageObjects }) { await testSubjects.click('docTableHeaderFieldSort_@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('Sep 17, 2015 @ 10:53:14.181\n-1'); }); await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName}`); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('Sep 17, 2015 @ 06:32:29.479\n20'); }); }); @@ -238,7 +238,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\ngood'); }); }); @@ -252,14 +252,14 @@ export default function ({ getService, getPageObjects }) { await testSubjects.click('docTableHeaderFieldSort_@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('Sep 17, 2015 @ 09:48:40.594\nbad'); }); await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('Sep 17, 2015 @ 06:32:29.479\ngood'); }); }); @@ -327,7 +327,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\ntrue'); }); }); @@ -354,14 +354,14 @@ export default function ({ getService, getPageObjects }) { await testSubjects.click('docTableHeaderFieldSort_@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('updateExpectedResultHere\ntrue'); }); await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('updateExpectedResultHere\nfalse'); }); }); @@ -417,7 +417,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('Sep 18, 2015 @ 06:52:55.953\n2015-09-18 07:00'); }); }); @@ -432,14 +432,14 @@ export default function ({ getService, getPageObjects }) { await testSubjects.click('docTableHeaderFieldSort_@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('updateExpectedResultHere\n2015-09-18 07:00'); }); await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async function () { - const rowData = await PageObjects.discover.getDocTableIndex(1); + const rowData = await PageObjects.discover.getDocTableIndexLegacy(1); expect(rowData).to.be('updateExpectedResultHere\n2015-09-18 07:00'); }); }); diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 34559afdf6ae1..b0610b36eb65f 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -220,14 +220,15 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide /** * Asserts that the toolbar pagination (count and arrows) is either displayed or not displayed. - * @param { displayed: boolean } + */ - public async expectToolbarPaginationDisplayed({ displayed = true }) { - const subjects = ['btnPrevPage', 'btnNextPage', 'toolBarPagerText']; - if (displayed) { - await Promise.all(subjects.map(async (subj) => await testSubjects.existOrFail(subj))); - } else { - await Promise.all(subjects.map(async (subj) => await testSubjects.missingOrFail(subj))); + public async expectToolbarPaginationDisplayed() { + const subjects = ['pagination-button-previous', 'pagination-button-next']; + + await Promise.all(subjects.map(async (subj) => await testSubjects.existOrFail(subj))); + const paginationListExists = await find.existsByCssSelector('.euiPagination__list'); + if (!paginationListExists) { + throw new Error(`expected discover data grid pagination list to exist`); } } @@ -413,16 +414,6 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await testSubjects.click('confirmSaveSavedObjectButton'); } - public async clickMarkdownQuickButton() { - log.debug('Click markdown quick button'); - await testSubjects.click('dashboardMarkdownQuickButton'); - } - - public async clickInputControlsQuickButton() { - log.debug('Click input controls quick button'); - await testSubjects.click('dashboardInputControlsQuickButton'); - } - /** * * @param dashboardTitle {String} diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 62375a39dd7d3..62aa41d89f75e 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -20,6 +20,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider const docTable = getService('docTable'); const config = getService('config'); const defaultFindTimeout = config.get('timeouts.find'); + const dataGrid = getService('dataGrid'); class DiscoverPage { public async getChartTimespan() { @@ -77,7 +78,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider } public async getColumnHeaders() { - return await docTable.getHeaderFields('embeddedSavedSearchDocTable'); + return await dataGrid.getHeaderFields(); } public async openLoadSavedSearchPanel() { @@ -139,7 +140,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider await elasticChart.waitForRenderComplete(); const el = await elasticChart.getCanvas(); - await browser.getActions().move({ x: 0, y: 20, origin: el._webElement }).click().perform(); + await browser.getActions().move({ x: 0, y: 0, origin: el._webElement }).click().perform(); } public async brushHistogram() { @@ -179,26 +180,31 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider } public async getDocHeader() { - const docHeader = await find.byCssSelector('thead > tr:nth-child(1)'); - return await docHeader.getVisibleText(); + const docHeader = await dataGrid.getHeaders(); + return docHeader.join(); } public async getDocTableRows() { await header.waitUntilLoadingHasFinished(); - const rows = await testSubjects.findAll('docTableRow'); - return rows; + return await dataGrid.getBodyRows(); } public async getDocTableIndex(index: number) { + const row = await dataGrid.getRow({ rowIndex: index - 1 }); + const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); + // Remove control columns + return result.slice(2).join(' '); + } + + public async getDocTableIndexLegacy(index: number) { const row = await find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); return await row.getVisibleText(); } - public async getDocTableField(index: number, cellIndex = 0) { - const fields = await find.allByCssSelector( - `tr.kbnDocTable__row:nth-child(${index}) [data-test-subj='docTableField']` - ); - return await fields[cellIndex].getVisibleText(); + public async getDocTableField(index: number, cellIdx: number = 2) { + const row = await dataGrid.getRow({ rowIndex: index - 1 }); + const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); + return result[cellIdx]; } public async skipToEndOfDocTable() { @@ -224,11 +230,11 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider } public async clickDocSortDown() { - await find.clickByCssSelector('.fa-sort-down'); + await dataGrid.clickDocSortAsc(); } public async clickDocSortUp() { - await find.clickByCssSelector('.fa-sort-up'); + await dataGrid.clickDocSortDesc(); } public async isShowingDocViewer() { @@ -237,10 +243,8 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider public async getMarks() { const table = await docTable.getTable(); - const $ = await table.parseDomContent(); - return $('mark') - .toArray() - .map((mark) => $(mark).text()); + const marks = await table.findAllByTagName('mark'); + return await Promise.all(marks.map((mark) => mark.getVisibleText())); } public async toggleSidebarCollapse() { @@ -295,8 +299,8 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider return await testSubjects.click(`field-${field}`); } - public async clickFieldSort(field: string) { - return await testSubjects.click(`docTableHeaderFieldSort_${field}`); + public async clickFieldSort(field: string, text = 'Sort New-Old') { + await dataGrid.clickDocSortAsc(field, text); } public async clickFieldListItemToggle(field: string) { @@ -368,8 +372,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider } public async removeHeaderColumn(name: string) { - await testSubjects.moveMouseTo(`docTableHeader-${name}`); - await testSubjects.click(`docTableRemoveHeader-${name}`); + await dataGrid.clickRemoveColumn(name); } public async openSidebarFieldFilter() { diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 7bb1603e0193f..a4e0c8b2647dd 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -30,15 +30,41 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }: FtrPro await PageObjects.common.sleep(500); } + async clickQuickButton(visType: string) { + log.debug(`DashboardAddPanel.clickQuickButton${visType}`); + await testSubjects.click(`dashboardQuickButton${visType}`); + } + + async clickMarkdownQuickButton() { + await this.clickQuickButton('markdown'); + } + + async clickMapQuickButton() { + await this.clickQuickButton('map'); + } + + async clickEditorMenuButton() { + log.debug('DashboardAddPanel.clickEditorMenuButton'); + await testSubjects.click('dashboardEditorMenuButton'); + } + + async clickAggBasedVisualizations() { + log.debug('DashboardAddPanel.clickEditorMenuAggBasedMenuItem'); + await testSubjects.click('dashboardEditorAggBasedMenuItem'); + } + async clickVisType(visType: string) { log.debug('DashboardAddPanel.clickVisType'); await testSubjects.click(`visType-${visType}`); } + async clickEmbeddableFactoryGroupButton(groupId: string) { + log.debug('DashboardAddPanel.clickEmbeddableFactoryGroupButton'); + await testSubjects.click(`dashboardEditorMenu-${groupId}Group`); + } + async clickAddNewEmbeddableLink(type: string) { - await testSubjects.click('createNew'); await testSubjects.click(`createNew-${type}`); - await testSubjects.missingOrFail(`createNew-${type}`); } async toggleFilterPopover() { diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index e4dc59ae71566..329a8204cce0e 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -47,14 +47,6 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi }); } - async docTableFieldCount(expectedCount: number) { - log.debug(`DashboardExpect.docTableFieldCount(${expectedCount})`); - await retry.try(async () => { - const docTableCells = await testSubjects.findAll('docTableField', findTimeout); - expect(docTableCells.length).to.be(expectedCount); - }); - } - async fieldSuggestions(expectedFields: string[]) { log.debug(`DashboardExpect.fieldSuggestions(${expectedFields})`); const fields = await filterBar.getFilterEditorFields(); diff --git a/test/functional/services/dashboard/visualizations.ts b/test/functional/services/dashboard/visualizations.ts index d1aaa6aa1bd70..2bf7458ff9c5f 100644 --- a/test/functional/services/dashboard/visualizations.ts +++ b/test/functional/services/dashboard/visualizations.ts @@ -10,8 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function DashboardVisualizationProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); - const find = getService('find'); - const retry = getService('retry'); const queryBar = getService('queryBar'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -31,8 +29,8 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await dashboardAddPanel.ensureAddPanelIsShowing(); - await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('metrics'); await PageObjects.visualize.clickVisualBuilder(); await PageObjects.visualize.saveVisualizationExpectSuccess(name); } @@ -87,39 +85,13 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F await dashboardAddPanel.addSavedSearch(name); } - async clickAddVisualizationButton() { - log.debug('DashboardVisualizations.clickAddVisualizationButton'); - await testSubjects.click('dashboardAddNewPanelButton'); - } - - async isNewVisDialogShowing() { - log.debug('DashboardVisualizations.isNewVisDialogShowing'); - return await find.existsByCssSelector('.visNewVisDialog'); - } - - async ensureNewVisualizationDialogIsShowing() { - let isShowing = await this.isNewVisDialogShowing(); - log.debug(`DashboardVisualizations.ensureNewVisualizationDialogIsShowing:${isShowing}`); - if (!isShowing) { - await retry.try(async () => { - await this.clickAddVisualizationButton(); - isShowing = await this.isNewVisDialogShowing(); - log.debug(`DashboardVisualizations.ensureNewVisualizationDialogIsShowing:${isShowing}`); - if (!isShowing) { - throw new Error('New Vis Dialog still not open, trying again.'); - } - }); - } - } - async createAndAddMarkdown({ name, markdown }: { name: string; markdown: string }) { log.debug(`createAndAddMarkdown(${markdown})`); const inViewMode = await PageObjects.dashboard.getIsInViewMode(); if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await this.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(markdown); await PageObjects.visEditor.clickGo(); await PageObjects.visualize.saveVisualizationExpectSuccess(name, { @@ -134,10 +106,10 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await this.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickAggBasedVisualizations(); - await PageObjects.visualize.clickMetric(); - await find.clickByCssSelector('li.euiListGroupItem:nth-of-type(2)'); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await dashboardAddPanel.clickVisType('metric'); + await testSubjects.click('savedObjectTitlelogstash-*'); await testSubjects.exists('visualizesaveAndReturnButton'); await testSubjects.click('visualizesaveAndReturnButton'); } @@ -148,8 +120,7 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await this.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(markdown); await PageObjects.visEditor.clickGo(); await testSubjects.click('visualizesaveAndReturnButton'); diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 87fa59b48a324..8ca6c6e816aa5 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -23,6 +23,7 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont const find = getService('find'); const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common', 'header']); + const retry = getService('retry'); class DataGrid { async getDataGridTableData(): Promise { @@ -126,6 +127,9 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont */ public async getDocTableRows() { const table = await this.getTable(); + if (!table) { + return []; + } const cells = await table.findAllByCssSelector('.euiDataGridRowCell'); const rows: WebElementWrapper[][] = []; @@ -183,14 +187,39 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont return await detailsRow.findAllByTestSubject('~docTableRowAction'); } - public async clickDocSortAsc() { - await find.clickByCssSelector('.euiDataGridHeaderCell__button'); - await find.clickByButtonText('Sort New-Old'); + public async openColMenuByField(field: string) { + await retry.waitFor('header cell action being displayed', async () => { + // to prevent flakiness + await testSubjects.click(`dataGridHeaderCell-${field}`); + return await testSubjects.exists(`dataGridHeaderCellActionGroup-${field}`); + }); + } + + public async clickDocSortAsc(field?: string, sortText = 'Sort New-Old') { + if (field) { + await this.openColMenuByField(field); + } else { + await find.clickByCssSelector('.euiDataGridHeaderCell__button'); + } + await find.clickByButtonText(sortText); + } + + public async clickDocSortDesc(field?: string, sortText = 'Sort Old-New') { + if (field) { + await this.openColMenuByField(field); + } else { + await find.clickByCssSelector('.euiDataGridHeaderCell__button'); + } + await find.clickByButtonText(sortText); } - public async clickDocSortDesc() { - await find.clickByCssSelector('.euiDataGridHeaderCell__button'); - await find.clickByButtonText('Sort Old-New'); + public async clickRemoveColumn(field?: string) { + if (field) { + await this.openColMenuByField(field); + } else { + await find.clickByCssSelector('.euiDataGridHeaderCell__button'); + } + await find.clickByButtonText('Remove column'); } public async getDetailsRow(): Promise { const detailRows = await this.getDetailsRows(); @@ -234,6 +263,10 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont await addInclusiveFilterButton.click(); await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); } + + public async hasNoResults() { + return await find.existsByCssSelector('.euiDataGrid__noResults'); + } } return new DataGrid(); diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index 7398e6ca8c12e..d4fa34f224547 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -50,15 +50,14 @@ export function SavedQueryManagementComponentProvider({ // an error. await testSubjects.click('savedQueryFormSaveButton'); - const saveQueryFormSaveButtonStatus = await testSubjects.isEnabled( - 'savedQueryFormSaveButton' - ); + await retry.waitForWithTimeout('save button to be disabled', 1000, async () => { + const saveQueryFormSaveButtonStatus = await testSubjects.isEnabled( + 'savedQueryFormSaveButton' + ); + return saveQueryFormSaveButtonStatus === false; + }); - try { - expect(saveQueryFormSaveButtonStatus).to.not.eql(true); - } finally { - await testSubjects.click('savedQueryFormCancelButton'); - } + await testSubjects.click('savedQueryFormCancelButton'); } public async saveCurrentlyLoadedAsNewQuery( diff --git a/test/new_visualize_flow/dashboard_embedding.ts b/test/new_visualize_flow/dashboard_embedding.ts index 6a1315dbfc91e..04b91542223ba 100644 --- a/test/new_visualize_flow/dashboard_embedding.ts +++ b/test/new_visualize_flow/dashboard_embedding.ts @@ -22,7 +22,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardExpect = getService('dashboardExpect'); - const testSubjects = getService('testSubjects'); const dashboardVisualizations = getService('dashboardVisualizations'); const PageObjects = getPageObjects([ 'common', @@ -47,8 +46,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adding a metric visualization', async function () { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); expect(originalPanelCount).to.eql(0); - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); await dashboardVisualizations.createAndEmbedMetric('Embedding Vis Test'); await PageObjects.dashboard.waitForRenderComplete(); await dashboardExpect.metricValuesExist(['0']); @@ -59,8 +56,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adding a markdown', async function () { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); expect(originalPanelCount).to.eql(1); - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); await dashboardVisualizations.createAndEmbedMarkdown({ name: 'Embedding Markdown Test', markdown: 'Nice to meet you, markdown is my name', diff --git a/test/plugin_functional/plugins/core_http/kibana.json b/test/plugin_functional/plugins/core_http/kibana.json new file mode 100644 index 0000000000000..69855f59d64b7 --- /dev/null +++ b/test/plugin_functional/plugins/core_http/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "coreHttp", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_http"], + "server": true, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_http/package.json b/test/plugin_functional/plugins/core_http/package.json new file mode 100644 index 0000000000000..fd0e80d3c1822 --- /dev/null +++ b/test/plugin_functional/plugins/core_http/package.json @@ -0,0 +1,14 @@ +{ + "name": "core_http", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_http", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/plugin_functional/plugins/core_http/public/index.ts b/test/plugin_functional/plugins/core_http/public/index.ts new file mode 100644 index 0000000000000..7b353cb1d2905 --- /dev/null +++ b/test/plugin_functional/plugins/core_http/public/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { PluginInitializer } from 'kibana/public'; +import { CoreHttpPlugin, CoreHttpPluginSetup, CoreHttpPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreHttpPlugin(); diff --git a/test/plugin_functional/plugins/core_http/public/plugin.tsx b/test/plugin_functional/plugins/core_http/public/plugin.tsx new file mode 100644 index 0000000000000..64b59847ffd1b --- /dev/null +++ b/test/plugin_functional/plugins/core_http/public/plugin.tsx @@ -0,0 +1,46 @@ +/* + * 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 { Plugin, CoreSetup } from 'kibana/public'; + +export class CoreHttpPlugin implements Plugin { + public setup({ http }: CoreSetup, deps: {}) { + const tryRequestCancellation = async () => { + const abortController = new AbortController(); + + const errorNamePromise = http + .get('/api/core_http/never_reply', { signal: abortController.signal }) + .then( + () => { + return undefined; + }, + (e) => { + return e.name; + } + ); + + // simulating 'real' cancellation by awaiting a bit + window.setTimeout(() => { + abortController.abort(); + }, 100); + + return errorNamePromise; + }; + + return { + tryRequestCancellation, + }; + } + + public start() {} + + public stop() {} +} + +export type CoreHttpPluginSetup = ReturnType; +export type CoreHttpPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_http/server/index.ts b/test/plugin_functional/plugins/core_http/server/index.ts new file mode 100644 index 0000000000000..66c2bb9e676f3 --- /dev/null +++ b/test/plugin_functional/plugins/core_http/server/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. + */ + +import { CoreHttpPlugin } from './plugin'; + +export const plugin = () => new CoreHttpPlugin(); diff --git a/test/plugin_functional/plugins/core_http/server/plugin.ts b/test/plugin_functional/plugins/core_http/server/plugin.ts new file mode 100644 index 0000000000000..533e3b22b368b --- /dev/null +++ b/test/plugin_functional/plugins/core_http/server/plugin.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Plugin, CoreSetup } from 'kibana/server'; + +export class CoreHttpPlugin implements Plugin { + public setup(core: CoreSetup, deps: {}) { + const router = core.http.createRouter(); + router.get( + { + path: '/api/core_http/never_reply', + validate: false, + }, + async (ctx, req, res) => { + // need the endpoint to never reply to test request cancelation on the client side. + await new Promise(() => undefined); + return res.ok(); + } + ); + } + + public start() {} + + public stop() {} +} diff --git a/test/plugin_functional/plugins/core_http/tsconfig.json b/test/plugin_functional/plugins/core_http/tsconfig.json new file mode 100644 index 0000000000000..3d9d8ca9451d4 --- /dev/null +++ b/test/plugin_functional/plugins/core_http/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/plugins/core_provider_plugin/kibana.json b/test/plugin_functional/plugins/core_provider_plugin/kibana.json index c55f62762e233..b3009b07de0a0 100644 --- a/test/plugin_functional/plugins/core_provider_plugin/kibana.json +++ b/test/plugin_functional/plugins/core_provider_plugin/kibana.json @@ -2,7 +2,13 @@ "id": "coreProviderPlugin", "version": "0.0.1", "kibanaVersion": "kibana", - "optionalPlugins": ["corePluginA", "corePluginB", "licensing", "globalSearchTest"], + "optionalPlugins": [ + "corePluginA", + "corePluginB", + "coreHttp", + "licensing", + "globalSearchTest" + ], "server": false, "ui": true } diff --git a/test/plugin_functional/test_suites/core_plugins/http.ts b/test/plugin_functional/test_suites/core_plugins/http.ts new file mode 100644 index 0000000000000..78682da70e608 --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/http.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + + const getCancelationErrorName = async () => { + return await browser.executeAsync(async (cb) => { + const errorName = await window._coreProvider.setup.plugins.coreHttp.tryRequestCancellation(); + cb(errorName); + }); + }; + + describe('http requests', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('home'); + }); + + it('returns correct name for aborted requests', async () => { + const canceledErrorName = await getCancelationErrorName(); + expect(canceledErrorName).to.eql('AbortError'); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 3f26b317b81ed..25868ac9a62db 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -21,5 +21,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./rendering')); loadTestFile(require.resolve('./chrome_help_menu_links')); loadTestFile(require.resolve('./history_block')); + loadTestFile(require.resolve('./http')); }); } diff --git a/test/plugin_functional/test_suites/data_plugin/session.ts b/test/plugin_functional/test_suites/data_plugin/session.ts index 0194a9c8e120b..ae4f8ffdf4072 100644 --- a/test/plugin_functional/test_suites/data_plugin/session.ts +++ b/test/plugin_functional/test_suites/data_plugin/session.ts @@ -54,7 +54,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide it('Starts a new session on sort', async () => { await PageObjects.discover.clickFieldListItemAdd('speaker'); - await PageObjects.discover.clickFieldSort('speaker'); + await PageObjects.discover.clickFieldSort('speaker', 'Sort A-Z'); await PageObjects.header.waitUntilLoadingHasFinished(); const sessionIds = await getSessionIds(); expect(sessionIds.length).to.be(1); diff --git a/test/plugin_functional/test_suites/saved_objects_management/find.ts b/test/plugin_functional/test_suites/saved_objects_management/find.ts index 5dce8f43339a1..e5a5d69c7e4d4 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/find.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/find.ts @@ -33,28 +33,17 @@ export default function ({ getService }: PluginFunctionalProviderContext) { .set('kbn-xsrf', 'true') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'test-hidden-importable-exportable', - id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', - attributes: { - title: 'Hidden Saved object type that is importable/exportable.', - }, - references: [], - updated_at: '2021-02-11T18:51:23.794Z', - version: 'WzIsMl0=', - namespaces: ['default'], - score: 0, - meta: { - namespaceType: 'single', - }, - }, - ], - }); + expect( + resp.body.saved_objects.map((so: { id: string; type: string }) => ({ + id: so.id, + type: so.type, + })) + ).to.eql([ + { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + }, + ]); })); it('returns empty response for non importableAndExportable types', async () => @@ -65,12 +54,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { .set('kbn-xsrf', 'true') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 0, - saved_objects: [], - }); + expect(resp.body.saved_objects).to.eql([]); })); }); }); diff --git a/test/scripts/checks/type_check.sh b/test/scripts/checks/type_check.sh index 5e091625de4ed..bbe5748558684 100755 --- a/test/scripts/checks/type_check.sh +++ b/test/scripts/checks/type_check.sh @@ -2,5 +2,13 @@ source src/dev/ci_setup/setup_env.sh +checks-reporter-with-killswitch "Build TS Refs" \ + node scripts/build_ts_refs \ + --ignore-type-failures \ + --clean \ + --no-cache \ + --force \ + --debug + checks-reporter-with-killswitch "Check Types" \ node scripts/type_check diff --git a/tsconfig.json b/tsconfig.json index 40763ede1bbdd..ac15fe14b4d2c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -105,6 +105,7 @@ { "path": "./x-pack/plugins/infra/tsconfig.json" }, { "path": "./x-pack/plugins/ingest_pipelines/tsconfig.json" }, { "path": "./x-pack/plugins/lens/tsconfig.json" }, + { "path": "./x-pack/plugins/license_api_guard/tsconfig.json" }, { "path": "./x-pack/plugins/license_management/tsconfig.json" }, { "path": "./x-pack/plugins/licensing/tsconfig.json" }, { "path": "./x-pack/plugins/logstash/tsconfig.json" }, diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 466a04d9b6b39..b8afdb9cde3ef 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -468,7 +468,13 @@ def allCiTasks() { }, jest: { workers.ci(name: 'jest', size: 'n2-standard-16', ramDisk: false) { - scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh')() + catchErrors { + scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh')() + } + + catchErrors { + runbld.junit() + } } }, ]) diff --git a/vars/workers.groovy b/vars/workers.groovy index 5d3328bc8a3c4..1260f74f1bdf9 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -101,6 +101,7 @@ def base(Map params, Closure closure) { "TEST_BROWSER_HEADLESS=1", "GIT_BRANCH=${checkoutInfo.branch}", "TMPDIR=${env.WORKSPACE}/tmp", // For Chrome and anything else that respects it + "BUILD_TS_REFS_DISABLE=true", // no need to build ts refs in bootstrap ]) { withCredentials([ string(credentialsId: 'vault-addr', variable: 'VAULT_ADDR'), diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 6bbbf6cd6b82d..4a03478800fc8 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -20,6 +20,7 @@ "xpack.endpoint": "plugins/endpoint", "xpack.enterpriseSearch": "plugins/enterprise_search", "xpack.features": "plugins/features", + "xpack.fileDataVisualizer": "plugins/file_data_visualizer", "xpack.fileUpload": "plugins/file_upload", "xpack.globalSearch": ["plugins/global_search"], "xpack.globalSearchBar": ["plugins/global_search_bar"], @@ -31,6 +32,7 @@ "xpack.fleet": "plugins/fleet", "xpack.ingestPipelines": "plugins/ingest_pipelines", "xpack.lens": "plugins/lens", + "xpack.licenseApiGuard": "plugins/license_api_guard", "xpack.licenseMgmt": "plugins/license_management", "xpack.licensing": "plugins/licensing", "xpack.lists": "plugins/lists", diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/drilldowns_with_embeddable_example.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/drilldowns_with_embeddable_example.tsx index e6351ec401d88..792b978c99241 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/drilldowns_with_embeddable_example.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/drilldowns_with_embeddable_example.tsx @@ -46,7 +46,7 @@ export const DrilldownsWithEmbeddableExample: React.FC = () => { ); const [showManager, setShowManager] = React.useState(false); const [openPopup, setOpenPopup] = React.useState(false); - const viewRef = React.useRef<'create' | 'manage'>('create'); + const viewRef = React.useRef<'/create' | '/manage'>('/create'); const panels: EuiContextMenuPanelDescriptor[] = [ { @@ -57,7 +57,7 @@ export const DrilldownsWithEmbeddableExample: React.FC = () => { icon: 'plusInCircle', onClick: () => { setOpenPopup(false); - viewRef.current = 'create'; + viewRef.current = '/create'; setShowManager((x) => !x); }, }, @@ -66,7 +66,7 @@ export const DrilldownsWithEmbeddableExample: React.FC = () => { icon: 'list', onClick: () => { setOpenPopup(false); - viewRef.current = 'manage'; + viewRef.current = '/manage'; setShowManager((x) => !x); }, }, @@ -122,12 +122,13 @@ export const DrilldownsWithEmbeddableExample: React.FC = () => { {showManager && ( setShowManager(false)} aria-labelledby="Drilldown Manager"> - setShowManager(false)} - viewMode={viewRef.current} + setShowManager(false)} /> )} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/drilldowns_without_embeddable_example.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/drilldowns_without_embeddable_example.tsx index d46c146b49916..a0e1f38543c29 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/drilldowns_without_embeddable_example.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/drilldowns_without_embeddable_example.tsx @@ -32,7 +32,7 @@ export const DrilldownsWithoutEmbeddableExample: React.FC = () => { const { plugins, managerWithoutEmbeddable } = useUiActions(); const [showManager, setShowManager] = React.useState(false); const [openPopup, setOpenPopup] = React.useState(false); - const viewRef = React.useRef<'create' | 'manage'>('create'); + const viewRef = React.useRef<'/create' | '/manage'>('/create'); const panels: EuiContextMenuPanelDescriptor[] = [ { @@ -43,7 +43,7 @@ export const DrilldownsWithoutEmbeddableExample: React.FC = () => { icon: 'plusInCircle', onClick: () => { setOpenPopup(false); - viewRef.current = 'create'; + viewRef.current = '/create'; setShowManager((x) => !x); }, }, @@ -52,7 +52,7 @@ export const DrilldownsWithoutEmbeddableExample: React.FC = () => { icon: 'list', onClick: () => { setOpenPopup(false); - viewRef.current = 'manage'; + viewRef.current = '/manage'; setShowManager((x) => !x); }, }, @@ -116,11 +116,12 @@ export const DrilldownsWithoutEmbeddableExample: React.FC = () => { {showManager && ( setShowManager(false)} aria-labelledby="Drilldown Manager"> - setShowManager(false)} - viewMode={viewRef.current} + setShowManager(false)} /> )} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/drilldowns_without_embeddable_single_button_example.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/drilldowns_without_embeddable_single_button_example.tsx index 7c19b091a8948..2d877eea7ce1f 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/drilldowns_without_embeddable_single_button_example.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/drilldowns_without_embeddable_single_button_example.tsx @@ -13,7 +13,6 @@ import { sampleApp2ClickContext, SAMPLE_APP2_CLICK_TRIGGER } from '../../trigger export const DrilldownsWithoutEmbeddableSingleButtonExample: React.FC = () => { const { plugins, managerWithoutEmbeddableSingleButton } = useUiActions(); const [showManager, setShowManager] = React.useState(false); - const viewRef = React.useRef<'create' | 'manage'>('create'); return ( <> @@ -50,11 +49,11 @@ export const DrilldownsWithoutEmbeddableSingleButtonExample: React.FC = () => { {showManager && ( setShowManager(false)} aria-labelledby="Drilldown Manager"> - setShowManager(false)} - viewMode={viewRef.current} + setShowManager(false)} /> )} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts index 7ab3b1feb0672..8496450745190 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts @@ -86,9 +86,9 @@ export class UiActionsEnhancedExamplesPlugin const { core: coreStart, plugins: pluginsStart, self } = start(); const handle = coreStart.overlays.openFlyout( toMountPoint( - h(pluginsStart.uiActionsEnhanced.FlyoutManageDrilldowns, { + h(pluginsStart.uiActionsEnhanced.DrilldownManager, { onClose: () => handle.close(), - viewMode: 'create', + initialRoute: '/create', dynamicActionManager: self.managerWithoutEmbeddableSingleButton, triggers: [SAMPLE_APP2_CLICK_TRIGGER], placeContext: {}, @@ -111,9 +111,9 @@ export class UiActionsEnhancedExamplesPlugin const { core: coreStart, plugins: pluginsStart, self } = start(); const handle = coreStart.overlays.openFlyout( toMountPoint( - h(pluginsStart.uiActionsEnhanced.FlyoutManageDrilldowns, { + h(pluginsStart.uiActionsEnhanced.DrilldownManager, { onClose: () => handle.close(), - viewMode: 'manage', + initialRoute: '/manage', dynamicActionManager: self.managerWithoutEmbeddableSingleButton, triggers: [SAMPLE_APP2_CLICK_TRIGGER], placeContext: { sampleApp2ClickContext }, diff --git a/x-pack/package.json b/x-pack/package.json index 36a6d120d946b..0c0924b51264a 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -27,14 +27,12 @@ "yarn": "^1.21.1" }, "devDependencies": { - "@kbn/babel-preset": "link:../packages/kbn-babel-preset", "@kbn/dev-utils": "link:../packages/kbn-dev-utils", "@kbn/es": "link:../packages/kbn-es", "@kbn/expect": "link:../packages/kbn-expect", "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/storybook": "link:../packages/kbn-storybook", - "@kbn/test": "link:../packages/kbn-test", - "@kbn/utility-types": "link:../packages/kbn-utility-types" + "@kbn/test": "link:../packages/kbn-test" }, "dependencies": { "@elastic/safer-lodash-set": "link:../packages/elastic-safer-lodash-set", diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index d8dcde2fab103..9f87de5f686cc 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -18,7 +18,7 @@ import { KibanaRequest, SavedObjectsUtils, } from '../../../../src/core/server'; -import { AuditLogger, EventOutcome } from '../../security/server'; +import { AuditLogger } from '../../security/server'; import { ActionType } from '../common'; import { ActionTypeRegistry } from './action_type_registry'; import { validateConfig, validateSecrets, ActionExecutorContract } from './lib'; @@ -146,7 +146,7 @@ export class ActionsClient { connectorAuditEvent({ action: ConnectorAuditAction.CREATE, savedObject: { type: 'action', id }, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', }) ); @@ -218,7 +218,7 @@ export class ActionsClient { connectorAuditEvent({ action: ConnectorAuditAction.UPDATE, savedObject: { type: 'action', id }, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', }) ); @@ -452,7 +452,7 @@ export class ActionsClient { this.auditLogger?.log( connectorAuditEvent({ action: ConnectorAuditAction.DELETE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'action', id }, }) ); diff --git a/x-pack/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/plugins/actions/server/builtin_action_types/server_log.ts index ac9c4211f07cc..6c54c1b9f2ff1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/server_log.ts @@ -9,7 +9,7 @@ import { curry } from 'lodash'; import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; -import { Logger } from '../../../../../src/core/server'; +import { Logger, LogMeta } from '../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; import { withoutControlCharacters } from './lib/string_utils'; @@ -66,7 +66,7 @@ async function executor( const sanitizedMessage = withoutControlCharacters(params.message); try { - logger[params.level](`Server log: ${sanitizedMessage}`); + (logger[params.level] as Logger['info'])(`Server log: ${sanitizedMessage}`); } catch (err) { const message = i18n.translate('xpack.actions.builtin.serverLog.errorLoggingErrorMessage', { defaultMessage: 'error logging message', diff --git a/x-pack/plugins/actions/server/lib/audit_events.test.ts b/x-pack/plugins/actions/server/lib/audit_events.test.ts index 6047a97b63c54..b30ccc1fb372b 100644 --- a/x-pack/plugins/actions/server/lib/audit_events.test.ts +++ b/x-pack/plugins/actions/server/lib/audit_events.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { EventOutcome } from '../../../security/server/audit'; import { ConnectorAuditAction, connectorAuditEvent } from './audit_events'; describe('#connectorAuditEvent', () => { @@ -13,7 +12,7 @@ describe('#connectorAuditEvent', () => { expect( connectorAuditEvent({ action: ConnectorAuditAction.CREATE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'action', id: 'ACTION_ID' }, }) ).toMatchInlineSnapshot(` @@ -21,9 +20,13 @@ describe('#connectorAuditEvent', () => { "error": undefined, "event": Object { "action": "connector_create", - "category": "database", + "category": Array [ + "database", + ], "outcome": "unknown", - "type": "creation", + "type": Array [ + "creation", + ], }, "kibana": Object { "saved_object": Object { @@ -47,9 +50,13 @@ describe('#connectorAuditEvent', () => { "error": undefined, "event": Object { "action": "connector_create", - "category": "database", + "category": Array [ + "database", + ], "outcome": "success", - "type": "creation", + "type": Array [ + "creation", + ], }, "kibana": Object { "saved_object": Object { @@ -77,9 +84,13 @@ describe('#connectorAuditEvent', () => { }, "event": Object { "action": "connector_create", - "category": "database", + "category": Array [ + "database", + ], "outcome": "failure", - "type": "creation", + "type": Array [ + "creation", + ], }, "kibana": Object { "saved_object": Object { diff --git a/x-pack/plugins/actions/server/lib/audit_events.ts b/x-pack/plugins/actions/server/lib/audit_events.ts index f80fa00e11641..5231c9bab7c37 100644 --- a/x-pack/plugins/actions/server/lib/audit_events.ts +++ b/x-pack/plugins/actions/server/lib/audit_events.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { AuditEvent, EventOutcome, EventCategory, EventType } from '../../../security/server'; +import type { EcsEventOutcome, EcsEventType } from 'src/core/server'; +import { AuditEvent } from '../../../security/server'; export enum ConnectorAuditAction { CREATE = 'connector_create', @@ -27,18 +28,18 @@ const eventVerbs: Record = { connector_execute: ['execute', 'executing', 'executed'], }; -const eventTypes: Record = { - connector_create: EventType.CREATION, - connector_get: EventType.ACCESS, - connector_update: EventType.CHANGE, - connector_delete: EventType.DELETION, - connector_find: EventType.ACCESS, +const eventTypes: Record = { + connector_create: 'creation', + connector_get: 'access', + connector_update: 'change', + connector_delete: 'deletion', + connector_find: 'access', connector_execute: undefined, }; export interface ConnectorAuditEventParams { action: ConnectorAuditAction; - outcome?: EventOutcome; + outcome?: EcsEventOutcome; savedObject?: NonNullable['saved_object']; error?: Error; } @@ -53,7 +54,7 @@ export function connectorAuditEvent({ const [present, progressive, past] = eventVerbs[action]; const message = error ? `Failed attempt to ${present} ${doc}` - : outcome === EventOutcome.UNKNOWN + : outcome === 'unknown' ? `User is ${progressive} ${doc}` : `User has ${past} ${doc}`; const type = eventTypes[action]; @@ -62,9 +63,9 @@ export function connectorAuditEvent({ message, event: { action, - category: EventCategory.DATABASE, - type, - outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS), + category: ['database'], + type: type ? [type] : undefined, + outcome: outcome ?? (error ? 'failure' : 'success'), }, kibana: { saved_object: savedObject, diff --git a/x-pack/plugins/actions/server/saved_objects/migrations.ts b/x-pack/plugins/actions/server/saved_objects/migrations.ts index 9b8b887fbec28..9bd54330f5d05 100644 --- a/x-pack/plugins/actions/server/saved_objects/migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/migrations.ts @@ -6,6 +6,7 @@ */ import { + LogMeta, SavedObjectMigrationMap, SavedObjectUnsanitizedDoc, SavedObjectMigrationFn, @@ -14,6 +15,10 @@ import { import { RawAction } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; +interface ActionsLogMeta extends LogMeta { + migrations: { actionDocument: SavedObjectUnsanitizedDoc }; +} + type ActionMigration = ( doc: SavedObjectUnsanitizedDoc ) => SavedObjectUnsanitizedDoc; @@ -50,9 +55,13 @@ function executeMigrationWithErrorHandling( try { return migrationFunc(doc, context); } catch (ex) { - context.log.error( + context.log.error( `encryptedSavedObject ${version} migration failed for action ${doc.id} with error: ${ex.message}`, - { actionDocument: doc } + { + migrations: { + actionDocument: doc, + }, + } ); } return doc; diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index e316ecd3c6fec..210bdf954ada4 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -51,7 +51,7 @@ import { IEventLogClient } from '../../../../plugins/event_log/server'; import { parseIsoOrRelativeDate } from '../lib/iso_or_relative_date'; import { alertInstanceSummaryFromEventLog } from '../lib/alert_instance_summary_from_event_log'; import { IEvent } from '../../../event_log/server'; -import { AuditLogger, EventOutcome } from '../../../security/server'; +import { AuditLogger } from '../../../security/server'; import { parseDuration } from '../../common/parse_duration'; import { retryIfConflicts } from '../lib/retry_if_conflicts'; import { partiallyUpdateAlert } from '../saved_objects'; @@ -293,7 +293,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.CREATE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id }, }) ); @@ -598,7 +598,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.DELETE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id }, }) ); @@ -671,7 +671,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.UPDATE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id }, }) ); @@ -850,7 +850,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.UPDATE_API_KEY, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id }, }) ); @@ -935,7 +935,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.ENABLE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id }, }) ); @@ -1036,7 +1036,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.DISABLE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id }, }) ); @@ -1112,7 +1112,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.MUTE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id }, }) ); @@ -1173,7 +1173,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.UNMUTE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id }, }) ); @@ -1234,7 +1234,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.MUTE_INSTANCE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id: alertId }, }) ); @@ -1300,7 +1300,7 @@ export class AlertsClient { this.auditLogger?.log( alertAuditEvent({ action: AlertAuditAction.UNMUTE_INSTANCE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id: alertId }, }) ); diff --git a/x-pack/plugins/alerting/server/alerts_client/audit_events.test.ts b/x-pack/plugins/alerting/server/alerts_client/audit_events.test.ts index fd79e9fac4fd1..4ccb69832cd26 100644 --- a/x-pack/plugins/alerting/server/alerts_client/audit_events.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/audit_events.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { EventOutcome } from '../../../security/server/audit'; import { AlertAuditAction, alertAuditEvent } from './audit_events'; describe('#alertAuditEvent', () => { @@ -13,7 +12,7 @@ describe('#alertAuditEvent', () => { expect( alertAuditEvent({ action: AlertAuditAction.CREATE, - outcome: EventOutcome.UNKNOWN, + outcome: 'unknown', savedObject: { type: 'alert', id: 'ALERT_ID' }, }) ).toMatchInlineSnapshot(` @@ -21,9 +20,13 @@ describe('#alertAuditEvent', () => { "error": undefined, "event": Object { "action": "alert_create", - "category": "database", + "category": Array [ + "database", + ], "outcome": "unknown", - "type": "creation", + "type": Array [ + "creation", + ], }, "kibana": Object { "saved_object": Object { @@ -47,9 +50,13 @@ describe('#alertAuditEvent', () => { "error": undefined, "event": Object { "action": "alert_create", - "category": "database", + "category": Array [ + "database", + ], "outcome": "success", - "type": "creation", + "type": Array [ + "creation", + ], }, "kibana": Object { "saved_object": Object { @@ -77,9 +84,13 @@ describe('#alertAuditEvent', () => { }, "event": Object { "action": "alert_create", - "category": "database", + "category": Array [ + "database", + ], "outcome": "failure", - "type": "creation", + "type": Array [ + "creation", + ], }, "kibana": Object { "saved_object": Object { diff --git a/x-pack/plugins/alerting/server/alerts_client/audit_events.ts b/x-pack/plugins/alerting/server/alerts_client/audit_events.ts index 354f58bafd888..93cca255d6ebc 100644 --- a/x-pack/plugins/alerting/server/alerts_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/alerts_client/audit_events.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { AuditEvent, EventOutcome, EventCategory, EventType } from '../../../security/server'; +import { EcsEventOutcome, EcsEventType } from 'src/core/server'; +import { AuditEvent } from '../../../security/server'; export enum AlertAuditAction { CREATE = 'alert_create', @@ -39,24 +40,24 @@ const eventVerbs: Record = { alert_instance_unmute: ['unmute instance of', 'unmuting instance of', 'unmuted instance of'], }; -const eventTypes: Record = { - alert_create: EventType.CREATION, - alert_get: EventType.ACCESS, - alert_update: EventType.CHANGE, - alert_update_api_key: EventType.CHANGE, - alert_enable: EventType.CHANGE, - alert_disable: EventType.CHANGE, - alert_delete: EventType.DELETION, - alert_find: EventType.ACCESS, - alert_mute: EventType.CHANGE, - alert_unmute: EventType.CHANGE, - alert_instance_mute: EventType.CHANGE, - alert_instance_unmute: EventType.CHANGE, +const eventTypes: Record = { + alert_create: 'creation', + alert_get: 'access', + alert_update: 'change', + alert_update_api_key: 'change', + alert_enable: 'change', + alert_disable: 'change', + alert_delete: 'deletion', + alert_find: 'access', + alert_mute: 'change', + alert_unmute: 'change', + alert_instance_mute: 'change', + alert_instance_unmute: 'change', }; export interface AlertAuditEventParams { action: AlertAuditAction; - outcome?: EventOutcome; + outcome?: EcsEventOutcome; savedObject?: NonNullable['saved_object']; error?: Error; } @@ -71,7 +72,7 @@ export function alertAuditEvent({ const [present, progressive, past] = eventVerbs[action]; const message = error ? `Failed attempt to ${present} ${doc}` - : outcome === EventOutcome.UNKNOWN + : outcome === 'unknown' ? `User is ${progressive} ${doc}` : `User has ${past} ${doc}`; const type = eventTypes[action]; @@ -80,9 +81,9 @@ export function alertAuditEvent({ message, event: { action, - category: EventCategory.DATABASE, - type, - outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS), + category: ['database'], + type: type ? [type] : undefined, + outcome: outcome ?? (error ? 'failure' : 'success'), }, kibana: { saved_object: savedObject, diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 787d3cc548ba1..1155cfa93337d 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -364,7 +364,7 @@ export class AlertingPlugin { } private createRouteHandlerContext = ( - core: CoreSetup + core: CoreSetup ): IContextProvider => { const { alertTypeRegistry, alertsClientFactory } = this; return async function alertsRouteHandlerContext(context, request) { @@ -376,6 +376,10 @@ export class AlertingPlugin { listTypes: alertTypeRegistry!.list.bind(alertTypeRegistry!), getFrameworkHealth: async () => await getHealth(savedObjects.createInternalRepository(['alert'])), + areApiKeysEnabled: async () => { + const [, { security }] = await core.getStartServices(); + return security?.authc.apiKeys.areAPIKeysEnabled() ?? false; + }, }; }; }; diff --git a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts index cd1c32a9b2d8f..7bab31fa4c89a 100644 --- a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts @@ -8,26 +8,23 @@ import { KibanaRequest, KibanaResponseFactory } from 'kibana/server'; import { identity } from 'lodash'; import type { MethodKeysOf } from '@kbn/utility-types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ScopedClusterClientMock } from '../../../../../src/core/server/elasticsearch/client/mocks'; import { httpServerMock } from '../../../../../src/core/server/mocks'; import { alertsClientMock, AlertsClientMock } from '../alerts_client.mock'; import { AlertsHealth, AlertType } from '../../common'; -import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import type { AlertingRequestHandlerContext } from '../types'; export function mockHandlerArguments( { alertsClient = alertsClientMock.create(), listTypes: listTypesRes = [], - esClient = elasticsearchServiceMock.createScopedClusterClient(), getFrameworkHealth, + areApiKeysEnabled, }: { alertsClient?: AlertsClientMock; listTypes?: AlertType[]; - esClient?: jest.Mocked; getFrameworkHealth?: jest.MockInstance, []> & (() => Promise); + areApiKeysEnabled?: () => Promise; }, req: unknown, res?: Array> @@ -39,13 +36,13 @@ export function mockHandlerArguments( const listTypes = jest.fn(() => listTypesRes); return [ ({ - core: { elasticsearch: { client: esClient } }, alerting: { listTypes, getAlertsClient() { return alertsClient || alertsClientMock.create(); }, getFrameworkHealth, + areApiKeysEnabled: areApiKeysEnabled ? areApiKeysEnabled : () => Promise.resolve(true), }, } as unknown) as AlertingRequestHandlerContext, req as KibanaRequest, diff --git a/x-pack/plugins/alerting/server/routes/health.test.ts b/x-pack/plugins/alerting/server/routes/health.test.ts index be63e0b7054be..40ad2721e816e 100644 --- a/x-pack/plugins/alerting/server/routes/health.test.ts +++ b/x-pack/plugins/alerting/server/routes/health.test.ts @@ -8,15 +8,12 @@ import { healthRoute } from './health'; import { httpServiceMock } from 'src/core/server/mocks'; import { mockHandlerArguments } from './_mock_handler_arguments'; -import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { verifyApiAccess } from '../lib/license_api_access'; import { licenseStateMock } from '../lib/license_state.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { alertsClientMock } from '../alerts_client.mock'; import { HealthStatus } from '../types'; import { alertsMock } from '../mocks'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { elasticsearchClientMock } from '../../../../../src/core/server/elasticsearch/client/mocks'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -65,25 +62,11 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({}) - ); - - const [context, req, res] = mockHandlerArguments({ esClient, alertsClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments({ alertsClient }, {}, ['ok']); await handler(context, req, res); expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); - - expect(esClient.asInternalUser.transport.request.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "method": "GET", - "path": "/_xpack/usage", - }, - ] - `); }); it('evaluates whether Encrypted Saved Objects is missing encryption key', async () => { @@ -94,13 +77,8 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({}) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, {}, ['ok'] ); @@ -135,13 +113,8 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({}) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, {}, ['ok'] ); @@ -176,13 +149,8 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ security: {} }) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, {}, ['ok'] ); @@ -217,13 +185,12 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ security: { enabled: true } }) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + alertsClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(false), + }, {}, ['ok'] ); @@ -258,15 +225,12 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - security: { enabled: true, ssl: {} }, - }) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + alertsClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(false), + }, {}, ['ok'] ); @@ -301,15 +265,8 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - security: { enabled: true, ssl: { http: { enabled: true } } }, - }) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, {}, ['ok'] ); diff --git a/x-pack/plugins/alerting/server/routes/health.ts b/x-pack/plugins/alerting/server/routes/health.ts index c2a122a28fa49..96016ccc45472 100644 --- a/x-pack/plugins/alerting/server/routes/health.ts +++ b/x-pack/plugins/alerting/server/routes/health.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { ApiResponse } from '@elastic/elasticsearch'; import { IRouter } from 'kibana/server'; import { ILicenseState } from '../lib'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; @@ -16,17 +15,6 @@ import { AlertingFrameworkHealth, } from '../types'; -interface XPackUsageSecurity { - security?: { - enabled?: boolean; - ssl?: { - http?: { - enabled?: boolean; - }; - }; - }; -} - const rewriteBodyRes: RewriteResponseCase = ({ isSufficientlySecure, hasPermanentEncryptionKey, @@ -56,23 +44,11 @@ export const healthRoute = ( router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { try { - const { - body: { - security: { - enabled: isSecurityEnabled = false, - ssl: { http: { enabled: isTLSEnabled = false } = {} } = {}, - } = {}, - }, - }: ApiResponse = await context.core.elasticsearch.client.asInternalUser.transport // Do not augment with such input. // `transport.request` is potentially unsafe when combined with untrusted user input. - .request({ - method: 'GET', - path: '/_xpack/usage', - }); - + const areApiKeysEnabled = await context.alerting.areApiKeysEnabled(); const alertingFrameworkHeath = await context.alerting.getFrameworkHealth(); const frameworkHealth: AlertingFrameworkHealth = { - isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), + isSufficientlySecure: areApiKeysEnabled, hasPermanentEncryptionKey: encryptedSavedObjects.canEncrypt, alertingFrameworkHeath, }; diff --git a/x-pack/plugins/alerting/server/routes/legacy/health.test.ts b/x-pack/plugins/alerting/server/routes/legacy/health.test.ts index 74de5f70a32e7..bc60eae34e0ce 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/health.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/health.test.ts @@ -8,8 +8,6 @@ import { healthRoute } from './health'; import { httpServiceMock } from 'src/core/server/mocks'; import { mockHandlerArguments } from './../_mock_handler_arguments'; -import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; -import { verifyApiAccess } from '../../lib/license_api_access'; import { licenseStateMock } from '../../lib/license_state.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; import { alertsClientMock } from '../../alerts_client.mock'; @@ -55,35 +53,6 @@ describe('healthRoute', () => { expect(config.path).toMatchInlineSnapshot(`"/api/alerts/_health"`); }); - it('queries the usage api', async () => { - const router = httpServiceMock.createRouter(); - - const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); - healthRoute(router, licenseState, encryptedSavedObjects); - const [, handler] = router.get.mock.calls[0]; - - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchServiceMock.createSuccessTransportRequestPromise({}) - ); - - const [context, req, res] = mockHandlerArguments({ esClient, alertsClient }, {}, ['ok']); - - await handler(context, req, res); - - expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); - - expect(esClient.asInternalUser.transport.request.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "method": "GET", - "path": "/_xpack/usage", - }, - ] - `); - }); - it('evaluates whether Encrypted Saved Objects is missing encryption key', async () => { const router = httpServiceMock.createRouter(); @@ -92,13 +61,8 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchServiceMock.createSuccessTransportRequestPromise({}) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, {}, ['ok'] ); @@ -133,13 +97,8 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchServiceMock.createSuccessTransportRequestPromise({}) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, {}, ['ok'] ); @@ -174,13 +133,8 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchServiceMock.createSuccessTransportRequestPromise({}) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, {}, ['ok'] ); @@ -215,13 +169,12 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchServiceMock.createSuccessTransportRequestPromise({ security: { enabled: true } }) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + alertsClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(false), + }, {}, ['ok'] ); @@ -256,15 +209,12 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchServiceMock.createSuccessTransportRequestPromise({ - security: { enabled: true, ssl: {} }, - }) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + alertsClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(false), + }, {}, ['ok'] ); @@ -299,15 +249,8 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createScopedClusterClient(); - esClient.asInternalUser.transport.request.mockReturnValue( - elasticsearchServiceMock.createSuccessTransportRequestPromise({ - security: { enabled: true, ssl: { http: { enabled: true } } }, - }) - ); - const [context, req, res] = mockHandlerArguments( - { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, {}, ['ok'] ); diff --git a/x-pack/plugins/alerting/server/routes/legacy/health.ts b/x-pack/plugins/alerting/server/routes/legacy/health.ts index b9906a56ce972..206a74c2ea636 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/health.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/health.ts @@ -5,24 +5,12 @@ * 2.0. */ -import { ApiResponse } from '@elastic/elasticsearch'; import type { AlertingRouter } from '../../types'; import { ILicenseState } from '../../lib/license_state'; import { verifyApiAccess } from '../../lib/license_api_access'; import { AlertingFrameworkHealth } from '../../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../../encrypted_saved_objects/server'; -interface XPackUsageSecurity { - security?: { - enabled?: boolean; - ssl?: { - http?: { - enabled?: boolean; - }; - }; - }; -} - export function healthRoute( router: AlertingRouter, licenseState: ILicenseState, @@ -39,23 +27,11 @@ export function healthRoute( return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); } try { - const { - body: { - security: { - enabled: isSecurityEnabled = false, - ssl: { http: { enabled: isTLSEnabled = false } = {} } = {}, - } = {}, - }, - }: ApiResponse = await context.core.elasticsearch.client.asInternalUser.transport // Do not augment with such input. // `transport.request` is potentially unsafe when combined with untrusted user input. - .request({ - method: 'GET', - path: '/_xpack/usage', - }); - const alertingFrameworkHeath = await context.alerting.getFrameworkHealth(); + const areApiKeysEnabled = await context.alerting.areApiKeysEnabled(); const frameworkHealth: AlertingFrameworkHealth = { - isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), + isSufficientlySecure: areApiKeysEnabled, hasPermanentEncryptionKey: encryptedSavedObjects.canEncrypt, alertingFrameworkHeath, }; diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 4df75ab60b496..4888116e43602 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -252,10 +252,12 @@ describe('7.10.0 migrates with failure', () => { expect(migrationContext.log.error).toHaveBeenCalledWith( `encryptedSavedObject 7.10.0 migration failed for alert ${alert.id} with error: Can't migrate!`, { - alertDocument: { - ...alert, - attributes: { - ...alert.attributes, + migrations: { + alertDocument: { + ...alert, + attributes: { + ...alert.attributes, + }, }, }, } @@ -973,6 +975,58 @@ describe('7.13.0', () => { }, }); }); + + test('security solution ML alert with string in machineLearningJobId is converted to an array', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + anomalyThreshold: 20, + machineLearningJobId: 'my_job_id', + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + anomalyThreshold: 20, + machineLearningJobId: ['my_job_id'], + exceptionsList: [], + riskScoreMapping: [], + severityMapping: [], + threat: [], + }, + }, + }); + }); + + test('security solution ML alert with an array in machineLearningJobId is preserved', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + anomalyThreshold: 20, + machineLearningJobId: ['my_job_id', 'my_other_job_id'], + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + anomalyThreshold: 20, + machineLearningJobId: ['my_job_id', 'my_other_job_id'], + exceptionsList: [], + riskScoreMapping: [], + severityMapping: [], + threat: [], + }, + }, + }); + }); }); function getUpdatedAt(): string { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 8ebeb401b313c..8969e3ad0fdef 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -6,6 +6,7 @@ */ import { + LogMeta, SavedObjectMigrationMap, SavedObjectUnsanitizedDoc, SavedObjectMigrationFn, @@ -20,6 +21,10 @@ const SIEM_APP_ID = 'securitySolution'; const SIEM_SERVER_APP_ID = 'siem'; export const LEGACY_LAST_MODIFIED_VERSION = 'pre-7.10.0'; +interface AlertLogMeta extends LogMeta { + migrations: { alertDocument: SavedObjectUnsanitizedDoc }; +} + type AlertMigration = ( doc: SavedObjectUnsanitizedDoc ) => SavedObjectUnsanitizedDoc; @@ -84,9 +89,13 @@ function executeMigrationWithErrorHandling( try { return migrationFunc(doc, context); } catch (ex) { - context.log.error( + context.log.error( `encryptedSavedObject ${version} migration failed for alert ${doc.id} with error: ${ex.message}`, - { alertDocument: doc } + { + migrations: { + alertDocument: doc, + }, + } ); } return doc; @@ -400,6 +409,12 @@ function removeNullsFromSecurityRules( ? params.lists : [], threatFilters: convertNullToUndefined(params.threatFilters), + machineLearningJobId: + params.machineLearningJobId == null + ? undefined + : Array.isArray(params.machineLearningJobId) + ? params.machineLearningJobId + : [params.machineLearningJobId], }, }, }; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 23aed1070a31a..dea5d7ddcc884 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -46,6 +46,7 @@ export interface AlertingApiRequestHandlerContext { getAlertsClient: () => AlertsClient; listTypes: AlertTypeRegistry['list']; getFrameworkHealth: () => Promise; + areApiKeysEnabled: () => Promise; } /** diff --git a/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap index 14343bd8d52c4..d7fc8e6442f12 100644 --- a/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap @@ -28,6 +28,9 @@ exports[`APM telemetry helpers getApmTelemetry generates a JSON object with the "nodejs": { "type": "long" }, + "php": { + "type": "long" + }, "python": { "type": "long" }, @@ -344,6 +347,60 @@ exports[`APM telemetry helpers getApmTelemetry generates a JSON object with the } } }, + "php": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "name": { + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "composite": { + "type": "keyword" + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "composite": { + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "name": { + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "composite": { + "type": "keyword" + } + } + } + } + } + } + }, "python": { "properties": { "agent": { diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index 1e18fe663ef20..0e565e1d88030 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -26,7 +26,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'The maximum total compressed size of the request body which is sent to the APM Server intake api via a chunked encoding (HTTP streaming).\nNote that a small overshoot is possible.\n\nAllowed byte units are `b`, `kb` and `mb`. `1kb` is equal to `1024b`.', } ), - excludeAgents: ['js-base', 'rum-js', 'dotnet', 'go', 'nodejs'], + excludeAgents: ['js-base', 'rum-js', 'dotnet', 'go', 'nodejs', 'php'], }, // API Request Time @@ -44,7 +44,7 @@ export const generalSettings: RawSettingDefinition[] = [ "Maximum time to keep an HTTP request to the APM Server open for.\n\nNOTE: This value has to be lower than the APM Server's `read_timeout` setting.", } ), - excludeAgents: ['js-base', 'rum-js', 'dotnet', 'go', 'nodejs'], + excludeAgents: ['js-base', 'rum-js', 'dotnet', 'go', 'nodejs', 'php'], }, // Capture body @@ -69,7 +69,7 @@ export const generalSettings: RawSettingDefinition[] = [ { text: 'transactions', value: 'transactions' }, { text: 'all', value: 'all' }, ], - excludeAgents: ['js-base', 'rum-js'], + excludeAgents: ['js-base', 'rum-js', 'php'], }, // Capture headers @@ -87,7 +87,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'If set to `true`, the agent will capture HTTP request and response headers (including cookies), as well as message headers/properties when using messaging frameworks (like Kafka).\n\nNOTE: Setting this to `false` reduces network bandwidth, disk space and object allocations.', } ), - excludeAgents: ['js-base', 'rum-js', 'nodejs'], + excludeAgents: ['js-base', 'rum-js', 'nodejs', 'php'], }, // LOG_LEVEL @@ -111,7 +111,7 @@ export const generalSettings: RawSettingDefinition[] = [ { text: 'critical', value: 'critical' }, { text: 'off', value: 'off' }, ], - includeAgents: ['dotnet', 'ruby', 'java', 'python', 'nodejs', 'go'], + includeAgents: ['dotnet', 'ruby', 'java', 'python', 'nodejs', 'go', 'php'], }, // Recording @@ -163,7 +163,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'In its default settings, the APM agent will collect a stack trace with every recorded span.\nWhile this is very helpful to find the exact place in your code that causes the span, collecting this stack trace does have some overhead. \nWhen setting this option to a negative value, like `-1ms`, stack traces will be collected for all spans. Setting it to a positive value, e.g. `5ms`, will limit stack trace collection to spans with durations equal to or longer than the given value, e.g. 5 milliseconds.\n\nTo disable stack trace collection for spans completely, set the value to `0ms`.', } ), - excludeAgents: ['js-base', 'rum-js', 'nodejs'], + excludeAgents: ['js-base', 'rum-js', 'nodejs', 'php'], }, // STACK_TRACE_LIMIT diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index a4560eb2ae17d..0ffa21cbd4a4d 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -157,9 +157,17 @@ describe('filterByAgent', () => { ]); }); + it('php', () => { + expect(getSettingKeysForAgent('php')).toEqual([ + 'log_level', + 'recording', + 'transaction_max_spans', + 'transaction_sample_rate', + ]); + }); + it('"All" services (no agent name)', () => { expect(getSettingKeysForAgent(undefined)).toEqual([ - 'capture_body', 'transaction_max_spans', 'transaction_sample_rate', ]); diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index 62bd07ce6f500..12df93d54b296 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -6,9 +6,9 @@ */ import { i18n } from '@kbn/i18n'; -import { ValuesType } from 'utility-types'; -import { ActionGroup } from '../../alerting/common'; -import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../../ml/common'; +import type { ValuesType } from 'utility-types'; +import type { ActionGroup } from '../../alerting/common'; +import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from './ml_constants'; export enum AlertType { ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts index b9cc3de8bb5d0..43a779407d2a4 100644 --- a/x-pack/plugins/apm/common/anomaly_detection.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ANOMALY_SEVERITY } from '../../ml/common'; +import { ANOMALY_SEVERITY } from './ml_constants'; import { getSeverityType, getSeverityColor as mlGetSeverityColor, diff --git a/x-pack/plugins/apm/common/ml_constants.ts b/x-pack/plugins/apm/common/ml_constants.ts new file mode 100644 index 0000000000000..7818299d9d883 --- /dev/null +++ b/x-pack/plugins/apm/common/ml_constants.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// copied from ml/common, to keep the bundle size small +export enum ANOMALY_SEVERITY { + CRITICAL = 'critical', + MAJOR = 'major', + MINOR = 'minor', + WARNING = 'warning', + LOW = 'low', + UNKNOWN = 'unknown', +} + +export enum ANOMALY_THRESHOLD { + CRITICAL = 75, + MAJOR = 50, + MINOR = 25, + WARNING = 3, + LOW = 0, +} diff --git a/x-pack/plugins/apm/common/rules.ts b/x-pack/plugins/apm/common/rules.ts deleted file mode 100644 index a3b60a785f5c7..0000000000000 --- a/x-pack/plugins/apm/common/rules.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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -const plainApmRuleRegistrySettings = { - name: 'apm', - fieldMap: { - 'service.environment': { - type: 'keyword', - }, - 'transaction.type': { - type: 'keyword', - }, - 'processor.event': { - type: 'keyword', - }, - }, -} as const; - -type APMRuleRegistrySettings = typeof plainApmRuleRegistrySettings; - -export const apmRuleRegistrySettings: APMRuleRegistrySettings = plainApmRuleRegistrySettings; diff --git a/x-pack/plugins/apm/common/rules/apm_rule_field_map.ts b/x-pack/plugins/apm/common/rules/apm_rule_field_map.ts new file mode 100644 index 0000000000000..9bbd9381c2319 --- /dev/null +++ b/x-pack/plugins/apm/common/rules/apm_rule_field_map.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const apmRuleFieldMap = { + 'service.environment': { + type: 'keyword', + }, + 'transaction.type': { + type: 'keyword', + }, + 'processor.event': { + type: 'keyword', + }, +} as const; + +export type APMRuleFieldMap = typeof apmRuleFieldMap; diff --git a/x-pack/plugins/apm/common/rules/apm_rule_registry_settings.ts b/x-pack/plugins/apm/common/rules/apm_rule_registry_settings.ts new file mode 100644 index 0000000000000..1257db4e6a4d3 --- /dev/null +++ b/x-pack/plugins/apm/common/rules/apm_rule_registry_settings.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const apmRuleRegistrySettings = { + name: 'apm', +}; diff --git a/x-pack/plugins/apm/common/service_health_status.ts b/x-pack/plugins/apm/common/service_health_status.ts index 71c373a48c9d5..b5318f9333e4f 100644 --- a/x-pack/plugins/apm/common/service_health_status.ts +++ b/x-pack/plugins/apm/common/service_health_status.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { EuiTheme } from '../../../../src/plugins/kibana_react/common'; -import { ANOMALY_SEVERITY } from '../../ml/common'; +import { ANOMALY_SEVERITY } from './ml_constants'; export enum ServiceHealthStatus { healthy = 'healthy', diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index 7df6ca343426c..e6415f76c60dc 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -8,9 +8,8 @@ import { act } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { Observable } from 'rxjs'; -import { AppMountParameters, CoreStart } from 'src/core/public'; +import { CoreStart } from 'src/core/public'; import { mockApmPluginContextValue } from '../context/apm_plugin/mock_apm_plugin_context'; -import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; import { renderApp } from './'; import { disableConsoleWarning } from '../utils/testHelpers'; @@ -40,7 +39,7 @@ describe('renderApp', () => { }); it('renders the app', () => { - const { core, config } = mockApmPluginContextValue; + const { core, config, apmRuleRegistry } = mockApmPluginContextValue; const plugins = { licensing: { license$: new Observable() }, triggersActionsUi: { actionTypeRegistry: {}, alertTypeRegistry: {} }, @@ -87,13 +86,14 @@ describe('renderApp', () => { let unmount: () => void; act(() => { - unmount = renderApp( - (core as unknown) as CoreStart, - (plugins as unknown) as ApmPluginSetupDeps, - (params as unknown) as AppMountParameters, + unmount = renderApp({ + coreStart: core as any, + pluginsSetup: plugins as any, + appMountParameters: params as any, + pluginsStart: startDeps as any, config, - (startDeps as unknown) as ApmPluginStartDeps - ); + apmRuleRegistry, + }); }); expect(() => { diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index 787b15d0a5675..b1cfd59a37cec 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -26,7 +26,11 @@ import { ApmPluginContext } from '../context/apm_plugin/apm_plugin_context'; import { UrlParamsProvider } from '../context/url_params_context/url_params_context'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { ConfigSchema } from '../index'; -import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; +import { + ApmPluginSetupDeps, + ApmPluginStartDeps, + ApmRuleRegistry, +} from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; import { px, units } from '../style/variables'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; @@ -72,12 +76,14 @@ export function CsmAppRoot({ deps, config, corePlugins: { embeddable, maps }, + apmRuleRegistry, }: { appMountParameters: AppMountParameters; core: CoreStart; deps: ApmPluginSetupDeps; config: ConfigSchema; corePlugins: ApmPluginStartDeps; + apmRuleRegistry: ApmRuleRegistry; }) { const { history } = appMountParameters; const i18nCore = core.i18n; @@ -87,7 +93,9 @@ export function CsmAppRoot({ config, core, plugins, + apmRuleRegistry, }; + return ( @@ -109,13 +117,21 @@ export function CsmAppRoot({ * This module is rendered asynchronously in the Kibana platform. */ -export const renderApp = ( - core: CoreStart, - deps: ApmPluginSetupDeps, - appMountParameters: AppMountParameters, - config: ConfigSchema, - corePlugins: ApmPluginStartDeps -) => { +export const renderApp = ({ + core, + deps, + appMountParameters, + config, + corePlugins, + apmRuleRegistry, +}: { + core: CoreStart; + deps: ApmPluginSetupDeps; + appMountParameters: AppMountParameters; + config: ConfigSchema; + corePlugins: ApmPluginStartDeps; + apmRuleRegistry: ApmRuleRegistry; +}) => { const { element } = appMountParameters; createCallApmApi(core); @@ -133,6 +149,7 @@ export const renderApp = ( deps={deps} config={config} corePlugins={corePlugins} + apmRuleRegistry={apmRuleRegistry} />, element ); diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index bc14bc1531686..acb55a02599f1 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -30,7 +30,11 @@ import { import { LicenseProvider } from '../context/license/license_context'; import { UrlParamsProvider } from '../context/url_params_context/url_params_context'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; -import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; +import { + ApmPluginSetupDeps, + ApmPluginStartDeps, + ApmRuleRegistry, +} from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { setHelpExtension } from '../setHelpExtension'; @@ -102,25 +106,34 @@ export function ApmAppRoot({ * This module is rendered asynchronously in the Kibana platform. */ -export const renderApp = ( - core: CoreStart, - setupDeps: ApmPluginSetupDeps, - appMountParameters: AppMountParameters, - config: ConfigSchema, - startDeps: ApmPluginStartDeps -) => { +export const renderApp = ({ + coreStart, + pluginsSetup, + appMountParameters, + config, + pluginsStart, + apmRuleRegistry, +}: { + coreStart: CoreStart; + pluginsSetup: ApmPluginSetupDeps; + appMountParameters: AppMountParameters; + config: ConfigSchema; + pluginsStart: ApmPluginStartDeps; + apmRuleRegistry: ApmRuleRegistry; +}) => { const { element } = appMountParameters; const apmPluginContextValue = { appMountParameters, config, - core, - plugins: setupDeps, + core: coreStart, + plugins: pluginsSetup, + apmRuleRegistry, }; // render APM feedback link in global help menu - setHelpExtension(core); - setReadonlyBadge(core); - createCallApmApi(core); + setHelpExtension(coreStart); + setReadonlyBadge(coreStart); + createCallApmApi(coreStart); // Automatically creates static index pattern and stores as saved object createStaticIndexPattern().catch((e) => { @@ -131,7 +144,7 @@ export const renderApp = ( ReactDOM.render( , element ); diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 8834cbc70e0b1..98c8b99411bc3 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -7,11 +7,20 @@ import { i18n } from '@kbn/i18n'; import { lazy } from 'react'; -import { format } from 'url'; +import { stringify } from 'querystring'; import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; -import { asDuration, asPercent } from '../../../common/utils/formatters'; import { AlertType } from '../../../common/alert_types'; -import { ApmRuleRegistry } from '../../plugin'; +import type { ApmRuleRegistry } from '../../plugin'; + +const format = ({ + pathname, + query, +}: { + pathname: string; + query: Record; +}): string => { + return `${pathname}?${stringify(query)}`; +}; export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) { apmRuleRegistry.registerType({ @@ -31,7 +40,7 @@ export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) { }, }), link: format({ - pathname: `/app/apm/services/${alert['service.name']!}`, + pathname: `/app/apm/services/${alert['service.name']!}/errors`, query: { ...(alert['service.environment'] ? { environment: alert['service.environment'] } @@ -71,7 +80,7 @@ export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) { 'Alert when the latency of a specific transaction type in a service exceeds a defined threshold.', } ), - format: ({ alert }) => ({ + format: ({ alert, formatters: { asDuration } }) => ({ reason: i18n.translate( 'xpack.apm.alertTypes.transactionDuration.reason', { @@ -131,7 +140,7 @@ export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) { 'Alert when the rate of transaction errors in a service exceeds a defined threshold.', } ), - format: ({ alert }) => ({ + format: ({ alert, formatters: { asPercent } }) => ({ reason: i18n.translate( 'xpack.apm.alertTypes.transactionErrorRate.reason', { diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx index 62926796cafb4..10d139f6ccea3 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx @@ -8,7 +8,7 @@ import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { ANOMALY_SEVERITY } from '../../../../../ml/common'; +import { ANOMALY_SEVERITY } from '../../../../common/ml_constants'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { ServiceAlertTrigger } from '../service_alert_trigger'; diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx index 85f48ae151e10..7b56eaa4721de 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React, { ReactNode } from 'react'; import { IntlProvider } from 'react-intl'; -import { ANOMALY_SEVERITY } from '../../../../../ml/common'; +import { ANOMALY_SEVERITY } from '../../../../common/ml_constants'; import { SelectAnomalySeverity } from './select_anomaly_severity'; function Wrapper({ children }: { children?: ReactNode }) { diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index a0bcfe60e72b5..19a567a3866bd 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -19,9 +19,12 @@ import { import { EuiTitle } from '@elastic/eui'; import d3 from 'd3'; import React from 'react'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { asRelativeDateTimeRange } from '../../../../../common/utils/formatters'; import { useTheme } from '../../../../hooks/use_theme'; +import { AlertType } from '../../../../../common/alert_types'; +import { getAlertAnnotations } from '../../../shared/charts/helper/get_alert_annotations'; type ErrorDistributionAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/errors/distribution'>; @@ -61,6 +64,8 @@ export function ErrorDistribution({ distribution, title }: Props) { const xFormatter = niceTimeFormatter([xMin, xMax]); + const { alerts } = useApmServiceContext(); + const tooltipProps: SettingsSpec['tooltip'] = { headerFormatter: (tooltip: TooltipValue) => { const serie = buckets.find((bucket) => bucket.x0 === tooltip.value); @@ -108,6 +113,12 @@ export function ErrorDistribution({ distribution, title }: Props) { data={buckets} color={theme.eui.euiColorVis1} /> + {getAlertAnnotations({ + alerts: alerts?.filter( + (alert) => alert['rule.id'] === AlertType.ErrorCount + ), + theme, + })} diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 5094287a402ea..b1bcf561bed84 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -4,6 +4,10 @@ exports[`Home component should render services 1`] = ` { setPercentileRange({ min: null, max: null }); }; - return ( - <> + return !isDisabled ? ( + - + {I18LABELS.resetZoom} - - ); + + ) : null; } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index 050d3b54a200f..b2e8ca5fda805 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -6,7 +6,14 @@ */ import React, { useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { I18LABELS } from '../translations'; @@ -14,6 +21,8 @@ import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; import { PageLoadDistChart } from '../Charts/PageLoadDistChart'; import { BreakdownItem } from '../../../../../typings/ui_filters'; import { ResetPercentileZoom } from './ResetPercentileZoom'; +import { createExploratoryViewUrl } from '../../../../../../observability/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; export interface PercentileRange { min?: number | null; @@ -21,9 +30,15 @@ export interface PercentileRange { } export function PageLoadDistribution() { + const { + services: { http }, + } = useKibana(); + const { urlParams, uiFilters } = useUrlParams(); - const { start, end, searchTerm } = urlParams; + const { start, end, rangeFrom, rangeTo, searchTerm } = urlParams; + + const { serviceName } = uiFilters; const [percentileRange, setPercentileRange] = useState({ min: null, @@ -34,8 +49,6 @@ export function PageLoadDistribution() { const { data, status } = useFetcher( (callApmApi) => { - const { serviceName } = uiFilters; - if (start && end && serviceName) { return callApmApi({ endpoint: 'GET /api/apm/rum-client/page-load-distribution', @@ -64,6 +77,7 @@ export function PageLoadDistribution() { percentileRange.min, percentileRange.max, searchTerm, + serviceName, ] ); @@ -71,6 +85,20 @@ export function PageLoadDistribution() { setPercentileRange({ min, max }); }; + const exploratoryViewLink = createExploratoryViewUrl( + { + [`${serviceName}-page-views`]: { + reportType: 'pld', + time: { from: rangeFrom!, to: rangeTo! }, + reportDefinitions: { + 'service.name': serviceName?.[0] as string, + }, + ...(breakdown ? { breakdown: breakdown.fieldName } : {}), + }, + }, + http?.basePath.get() + ); + return (
@@ -79,12 +107,10 @@ export function PageLoadDistribution() {

{I18LABELS.pageLoadDistribution}

- - - + + + + + +
(null); const { data, status } = useFetcher( (callApmApi) => { - const { serviceName } = uiFilters; - if (start && end && serviceName) { return callApmApi({ endpoint: 'GET /api/apm/rum-client/page-view-trends', @@ -45,7 +57,21 @@ export function PageViewsTrend() { } return Promise.resolve(undefined); }, - [end, start, uiFilters, breakdown, searchTerm] + [start, end, serviceName, uiFilters, searchTerm, breakdown] + ); + + const exploratoryViewLink = createExploratoryViewUrl( + { + [`${serviceName}-page-views`]: { + reportType: 'kpi', + time: { from: rangeFrom!, to: rangeTo! }, + reportDefinitions: { + 'service.name': serviceName?.[0] as string, + }, + ...(breakdown ? { breakdown: breakdown.fieldName } : {}), + }, + }, + http?.basePath.get() ); return ( @@ -63,6 +89,18 @@ export function PageViewsTrend() { dataTestSubj={'pvBreakdownFilter'} /> + + + + + diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap index f521695177e05..a3074bf66a052 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap @@ -268,16 +268,15 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` Occurrences - - Click to sort in ascending order - + + + Click to sort in ascending order @@ -309,11 +308,11 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` > Latest occurrence - - Click to sort in ascending order - + + + Click to sort in ascending order @@ -688,16 +687,15 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` Occurrences - - Click to sort in ascending order - + + + Click to sort in ascending order @@ -729,11 +727,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > Latest occurrence - - Click to sort in ascending order - + + + Click to sort in ascending order diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx new file mode 100644 index 0000000000000..7607b6fd91392 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { parse, format } from 'url'; +import { uniqBy } from 'lodash'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { asPercent, asDuration } from '../../../../../common/utils/formatters'; +import { TimestampTooltip } from '../../../shared/TimestampTooltip'; + +interface AlertDetailProps { + alerts: APIReturnType<'GET /api/apm/services/{serviceName}/alerts'>['alerts']; +} + +export function AlertDetails({ alerts }: AlertDetailProps) { + const { + apmRuleRegistry, + core: { + http: { + basePath: { prepend }, + }, + }, + } = useApmPluginContext(); + + const { + urlParams: { rangeFrom, rangeTo }, + } = useUrlParams(); + + const collapsedAlerts = uniqBy( + alerts, + (alert) => alert['kibana.rac.alert.id']! + ); + + return ( + + {collapsedAlerts.map((alert) => { + const ruleType = apmRuleRegistry.getTypeByRuleId(alert['rule.id']!); + const formatted = { + link: undefined, + reason: alert['rule.name'], + ...(ruleType?.format?.({ + alert, + formatters: { asDuration, asPercent }, + }) ?? {}), + }; + + const parsedLink = formatted.link + ? parse(formatted.link, true) + : undefined; + + return ( + + + + {parsedLink ? ( + + {formatted.reason} + + ) : ( + formatted.reason + )} + + + + + + + ); + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx index f7495d3e51671..79f93ea76ee51 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx @@ -23,7 +23,11 @@ interface IconPopoverProps { onClose: () => void; detailsFetchStatus: FETCH_STATUS; isOpen: boolean; - icon?: string; + icon: { + type?: string; + size?: 's' | 'm' | 'l'; + color?: string; + }; } export function IconPopover({ icon, @@ -34,7 +38,7 @@ export function IconPopover({ detailsFetchStatus, isOpen, }: IconPopoverProps) { - if (!icon) { + if (!icon.type) { return null; } const isLoading = detailsFetchStatus === FETCH_STATUS.LOADING; @@ -44,7 +48,11 @@ export function IconPopover({ ownFocus={false} button={ - + } isOpen={isOpen} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx index 6f9c82200fb60..7dde7ed3d145d 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactChild, useState } from 'react'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useTheme } from '../../../../hooks/use_theme'; import { ContainerType } from '../../../../../common/service_metadata'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; @@ -17,6 +18,7 @@ import { CloudDetails } from './cloud_details'; import { ContainerDetails } from './container_details'; import { IconPopover } from './icon_popover'; import { ServiceDetails } from './service_details'; +import { AlertDetails } from './alert_details'; interface Props { serviceName: string; @@ -46,10 +48,15 @@ function getContainerIcon(container?: ContainerType) { } } -type Icons = 'service' | 'container' | 'cloud'; +type Icons = 'service' | 'container' | 'cloud' | 'alerts'; + interface PopoverItem { key: Icons; - icon?: string; + icon: { + type?: string; + color?: string; + size?: 's' | 'm' | 'l'; + }; isVisible: boolean; title: string; component: ReactChild; @@ -66,6 +73,8 @@ export function ServiceIcons({ serviceName }: Props) { const theme = useTheme(); + const { alerts } = useApmServiceContext(); + const { data: icons, status: iconsFetchStatus } = useFetcher( (callApmApi) => { if (serviceName && start && end) { @@ -106,7 +115,9 @@ export function ServiceIcons({ serviceName }: Props) { const popoverItems: PopoverItem[] = [ { key: 'service', - icon: getAgentIcon(icons?.agentName, theme.darkMode) || 'node', + icon: { + type: getAgentIcon(icons?.agentName, theme.darkMode) || 'node', + }, isVisible: !!icons?.agentName, title: i18n.translate('xpack.apm.serviceIcons.service', { defaultMessage: 'Service', @@ -115,7 +126,9 @@ export function ServiceIcons({ serviceName }: Props) { }, { key: 'container', - icon: getContainerIcon(icons?.containerType), + icon: { + type: getContainerIcon(icons?.containerType), + }, isVisible: !!icons?.containerType, title: i18n.translate('xpack.apm.serviceIcons.container', { defaultMessage: 'Container', @@ -124,13 +137,28 @@ export function ServiceIcons({ serviceName }: Props) { }, { key: 'cloud', - icon: getCloudIcon(icons?.cloudProvider), + icon: { + type: getCloudIcon(icons?.cloudProvider), + }, isVisible: !!icons?.cloudProvider, title: i18n.translate('xpack.apm.serviceIcons.cloud', { defaultMessage: 'Cloud', }), component: , }, + { + key: 'alerts', + icon: { + type: 'bell', + color: theme.eui.euiColorDanger, + size: 'm', + }, + isVisible: alerts.length > 0, + title: i18n.translate('xpack.apm.serviceIcons.alerts', { + defaultMessage: 'Alerts', + }), + component: , + }, ]; return ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index c6ed4e640693f..4d6c0be9ff818 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -72,6 +72,7 @@ describe('ServiceOverview', () => { agentName: 'java', transactionType: 'request', transactionTypes: ['request'], + alerts: [], }); jest .spyOn(useAnnotationsHooks, 'useAnnotationsContext') @@ -85,16 +86,16 @@ describe('ServiceOverview', () => { /* eslint-disable @typescript-eslint/naming-convention */ const calls = { - 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics': { + 'GET /api/apm/services/{serviceName}/error_groups/main_statistics': { error_groups: [] as any[], }, - 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics': { + 'GET /api/apm/services/{serviceName}/transactions/groups/main_statistics': { transactionGroups: [] as any[], totalTransactionGroups: 0, isAggregationAccurate: true, }, 'GET /api/apm/services/{serviceName}/dependencies': [], - 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics': [], + 'GET /api/apm/services/{serviceName}/service_overview_instances/main_statistics': [], }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/get_column.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/get_column.tsx index fd1120808db9e..4ad83f7d87426 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/get_column.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/get_column.tsx @@ -16,18 +16,18 @@ import { TimestampTooltip } from '../../../shared/TimestampTooltip'; import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; -type ErrorGroupPrimaryStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/error_groups/primary_statistics'>; -type ErrorGroupComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/error_groups/comparison_statistics'>; +type ErrorGroupMainStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/error_groups/main_statistics'>; +type ErrorGroupDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/error_groups/detailed_statistics'>; export function getColumns({ serviceName, - errorGroupComparisonStatistics, + errorGroupDetailedStatistics, comparisonEnabled, }: { serviceName: string; - errorGroupComparisonStatistics: ErrorGroupComparisonStatistics; + errorGroupDetailedStatistics: ErrorGroupDetailedStatistics; comparisonEnabled?: boolean; -}): Array> { +}): Array> { return [ { field: 'name', @@ -74,10 +74,10 @@ export function getColumns({ width: px(unit * 12), render: (_, { occurrences, group_id: errorGroupId }) => { const currentPeriodTimeseries = - errorGroupComparisonStatistics?.currentPeriod?.[errorGroupId] + errorGroupDetailedStatistics?.currentPeriod?.[errorGroupId] ?.timeseries; const previousPeriodTimeseries = - errorGroupComparisonStatistics?.previousPeriod?.[errorGroupId] + errorGroupDetailedStatistics?.previousPeriod?.[errorGroupId] ?.timeseries; return ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index d36bee8d6be73..7c222f85133e3 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -28,8 +28,8 @@ import { getColumns } from './get_column'; interface Props { serviceName: string; } -type ErrorGroupPrimaryStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/error_groups/primary_statistics'>; -type ErrorGroupComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/error_groups/comparison_statistics'>; +type ErrorGroupMainStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/error_groups/main_statistics'>; +type ErrorGroupDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/error_groups/detailed_statistics'>; type SortDirection = 'asc' | 'desc'; type SortField = 'name' | 'last_seen' | 'occurrences'; @@ -40,8 +40,8 @@ const DEFAULT_SORT = { field: 'occurrences' as const, }; -const INITIAL_STATE_PRIMARY_STATISTICS: { - items: ErrorGroupPrimaryStatistics['error_groups']; +const INITIAL_STATE_MAIN_STATISTICS: { + items: ErrorGroupMainStatistics['error_groups']; totalItems: number; requestId?: string; } = { @@ -50,7 +50,7 @@ const INITIAL_STATE_PRIMARY_STATISTICS: { requestId: undefined, }; -const INITIAL_STATE_COMPARISON_STATISTICS: ErrorGroupComparisonStatistics = { +const INITIAL_STATE_DETAILED_STATISTICS: ErrorGroupDetailedStatistics = { currentPeriod: {}, previousPeriod: {}, }; @@ -82,19 +82,20 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { start, end, comparisonType, + comparisonEnabled, }); const { pageIndex, sort } = tableOptions; const { direction, field } = sort; - const { data = INITIAL_STATE_PRIMARY_STATISTICS, status } = useFetcher( + const { data = INITIAL_STATE_MAIN_STATISTICS, status } = useFetcher( (callApmApi) => { if (!start || !end || !transactionType) { return; } return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics', + 'GET /api/apm/services/{serviceName}/error_groups/main_statistics', params: { path: { serviceName }, query: { @@ -113,13 +114,13 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); return { + // Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched. requestId: uuid(), items: currentPageErrorGroups, totalItems: response.error_groups.length, }; }); }, - // comparisonType is listed as dependency even thought it is not used. This is needed to trigger the comparison api when it is changed. // eslint-disable-next-line react-hooks/exhaustive-deps [ environment, @@ -131,21 +132,24 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { pageIndex, direction, field, + // not used, but needed to trigger an update when comparisonType is changed either manually by user or when time range is changed comparisonType, + // not used, but needed to trigger an update when comparison feature is disabled/enabled by user + comparisonEnabled, ] ); const { requestId, items, totalItems } = data; const { - data: errorGroupComparisonStatistics = INITIAL_STATE_COMPARISON_STATISTICS, - status: errorGroupComparisonStatisticsStatus, + data: errorGroupDetailedStatistics = INITIAL_STATE_DETAILED_STATISTICS, + status: errorGroupDetailedStatisticsStatus, } = useFetcher( (callApmApi) => { if (requestId && items.length && start && end && transactionType) { return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/error_groups/comparison_statistics', + 'GET /api/apm/services/{serviceName}/error_groups/detailed_statistics', params: { path: { serviceName }, query: { @@ -173,7 +177,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { const columns = getColumns({ serviceName, - errorGroupComparisonStatistics, + errorGroupDetailedStatistics, comparisonEnabled, }); @@ -218,7 +222,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { }} loading={ status === FETCH_STATUS.LOADING || - errorGroupComparisonStatisticsStatus === FETCH_STATUS.LOADING + errorGroupDetailedStatisticsStatus === FETCH_STATUS.LOADING } onChange={(newTableOptions: { page?: { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index 55eb2e3ddab73..8305b5a0dde3b 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -25,7 +25,7 @@ interface ServiceOverviewInstancesChartAndTableProps { serviceName: string; } -export interface PrimaryStatsServiceInstanceItem { +export interface MainStatsServiceInstanceItem { serviceNodeName: string; errorRate: number; throughput: number; @@ -34,15 +34,15 @@ export interface PrimaryStatsServiceInstanceItem { memoryUsage: number; } -const INITIAL_STATE_PRIMARY_STATS = { - primaryStatsItems: [] as PrimaryStatsServiceInstanceItem[], - primaryStatsRequestId: undefined, - primaryStatsItemCount: 0, +const INITIAL_STATE_MAIN_STATS = { + mainStatsItems: [] as MainStatsServiceInstanceItem[], + mainStatsRequestId: undefined, + mainStatsItemCount: 0, }; -type ApiResponseComparisonStats = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics'>; +type ApiResponseDetailedStats = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics'>; -const INITIAL_STATE_COMPARISON_STATISTICS: ApiResponseComparisonStats = { +const INITIAL_STATE_DETAILED_STATISTICS: ApiResponseDetailedStats = { currentPeriod: {}, previousPeriod: {}, }; @@ -83,6 +83,7 @@ export function ServiceOverviewInstancesChartAndTable({ start, end, comparisonType, + comparisonEnabled, }, } = useUrlParams(); @@ -90,11 +91,12 @@ export function ServiceOverviewInstancesChartAndTable({ start, end, comparisonType, + comparisonEnabled, }); const { - data: primaryStatsData = INITIAL_STATE_PRIMARY_STATS, - status: primaryStatsStatus, + data: mainStatsData = INITIAL_STATE_MAIN_STATS, + status: mainStatsStatus, } = useFetcher( (callApmApi) => { if (!start || !end || !transactionType || !latencyAggregationType) { @@ -103,7 +105,7 @@ export function ServiceOverviewInstancesChartAndTable({ return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics', + 'GET /api/apm/services/{serviceName}/service_overview_instances/main_statistics', params: { path: { serviceName, @@ -118,7 +120,7 @@ export function ServiceOverviewInstancesChartAndTable({ }, }, }).then((response) => { - const primaryStatsItems = orderBy( + const mainStatsItems = orderBy( // need top-level sortable fields for the managed table response.serviceInstances.map((item) => ({ ...item, @@ -133,13 +135,13 @@ export function ServiceOverviewInstancesChartAndTable({ ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); return { - primaryStatsRequestId: uuid(), - primaryStatsItems, - primaryStatsItemCount: response.serviceInstances.length, + // Everytime the main statistics is refetched, updates the requestId making the detailed API to be refetched. + mainStatsRequestId: uuid(), + mainStatsItems, + mainStatsItemCount: response.serviceInstances.length, }; }); }, - // comparisonType is listed as dependency even thought it is not used. This is needed to trigger the comparison api when it is changed. // eslint-disable-next-line react-hooks/exhaustive-deps [ environment, @@ -152,19 +154,22 @@ export function ServiceOverviewInstancesChartAndTable({ pageIndex, field, direction, + // not used, but needed to trigger an update when comparisonType is changed either manually by user or when time range is changed comparisonType, + // not used, but needed to trigger an update when comparison feature is disabled/enabled by user + comparisonEnabled, ] ); const { - primaryStatsItems, - primaryStatsRequestId, - primaryStatsItemCount, - } = primaryStatsData; + mainStatsItems, + mainStatsRequestId, + mainStatsItemCount, + } = mainStatsData; const { - data: comparisonStatsData = INITIAL_STATE_COMPARISON_STATISTICS, - status: comparisonStatisticsStatus, + data: detailedStatsData = INITIAL_STATE_DETAILED_STATISTICS, + status: detailedStatsStatus, } = useFetcher( (callApmApi) => { if ( @@ -172,14 +177,14 @@ export function ServiceOverviewInstancesChartAndTable({ !end || !transactionType || !latencyAggregationType || - !primaryStatsItemCount + !mainStatsItemCount ) { return; } return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics', + 'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics', params: { path: { serviceName, @@ -193,7 +198,7 @@ export function ServiceOverviewInstancesChartAndTable({ numBuckets: 20, transactionType, serviceNodeIds: JSON.stringify( - primaryStatsItems.map((item) => item.serviceNodeName) + mainStatsItems.map((item) => item.serviceNodeName) ), comparisonStart, comparisonEnd, @@ -201,9 +206,9 @@ export function ServiceOverviewInstancesChartAndTable({ }, }); }, - // only fetches comparison statistics when requestId is invalidated by primary statistics api call + // only fetches detailed statistics when requestId is invalidated by main statistics api call // eslint-disable-next-line react-hooks/exhaustive-deps - [primaryStatsRequestId], + [mainStatsRequestId], { preservePreviousData: false } ); @@ -212,22 +217,22 @@ export function ServiceOverviewInstancesChartAndTable({ { setTableOptions({ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx index d61593f52b2ed..f52c2b083330f 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -26,23 +26,23 @@ import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink'; import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; import { getLatencyColumnLabel } from '../get_latency_column_label'; -import { PrimaryStatsServiceInstanceItem } from '../service_overview_instances_chart_and_table'; +import { MainStatsServiceInstanceItem } from '../service_overview_instances_chart_and_table'; -type ServiceInstanceComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics'>; +type ServiceInstanceDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics'>; export function getColumns({ serviceName, agentName, latencyAggregationType, - comparisonStatsData, + detailedStatsData, comparisonEnabled, }: { serviceName: string; agentName?: string; latencyAggregationType?: LatencyAggregationType; - comparisonStatsData?: ServiceInstanceComparisonStatistics; + detailedStatsData?: ServiceInstanceDetailedStatistics; comparisonEnabled?: boolean; -}): Array> { +}): Array> { return [ { field: 'serviceNodeName', @@ -87,9 +87,9 @@ export function getColumns({ width: px(unit * 10), render: (_, { serviceNodeName, latency }) => { const currentPeriodTimestamp = - comparisonStatsData?.currentPeriod?.[serviceNodeName]?.latency; + detailedStatsData?.currentPeriod?.[serviceNodeName]?.latency; const previousPeriodTimestamp = - comparisonStatsData?.previousPeriod?.[serviceNodeName]?.latency; + detailedStatsData?.previousPeriod?.[serviceNodeName]?.latency; return ( { const currentPeriodTimestamp = - comparisonStatsData?.currentPeriod?.[serviceNodeName]?.throughput; + detailedStatsData?.currentPeriod?.[serviceNodeName]?.throughput; const previousPeriodTimestamp = - comparisonStatsData?.previousPeriod?.[serviceNodeName]?.throughput; + detailedStatsData?.previousPeriod?.[serviceNodeName]?.throughput; return ( { const currentPeriodTimestamp = - comparisonStatsData?.currentPeriod?.[serviceNodeName]?.errorRate; + detailedStatsData?.currentPeriod?.[serviceNodeName]?.errorRate; const previousPeriodTimestamp = - comparisonStatsData?.previousPeriod?.[serviceNodeName]?.errorRate; + detailedStatsData?.previousPeriod?.[serviceNodeName]?.errorRate; return ( { const currentPeriodTimestamp = - comparisonStatsData?.currentPeriod?.[serviceNodeName]?.cpuUsage; + detailedStatsData?.currentPeriod?.[serviceNodeName]?.cpuUsage; const previousPeriodTimestamp = - comparisonStatsData?.previousPeriod?.[serviceNodeName]?.cpuUsage; + detailedStatsData?.previousPeriod?.[serviceNodeName]?.cpuUsage; return ( { const currentPeriodTimestamp = - comparisonStatsData?.currentPeriod?.[serviceNodeName]?.memoryUsage; + detailedStatsData?.currentPeriod?.[serviceNodeName]?.memoryUsage; const previousPeriodTimestamp = - comparisonStatsData?.previousPeriod?.[serviceNodeName]?.memoryUsage; + detailedStatsData?.previousPeriod?.[serviceNodeName]?.memoryUsage; return ( ; +type ServiceInstanceDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics'>; export interface TableOptions { pageIndex: number; @@ -38,26 +38,26 @@ export interface TableOptions { } interface Props { - primaryStatsItems: PrimaryStatsServiceInstanceItem[]; + mainStatsItems: MainStatsServiceInstanceItem[]; serviceName: string; - primaryStatsStatus: FETCH_STATUS; - primaryStatsItemCount: number; + mainStatsStatus: FETCH_STATUS; + mainStatsItemCount: number; tableOptions: TableOptions; onChangeTableOptions: (newTableOptions: { page?: { index: number }; sort?: { field: string; direction: SortDirection }; }) => void; - comparisonStatsData?: ServiceInstanceComparisonStatistics; + detailedStatsData?: ServiceInstanceDetailedStatistics; isLoading: boolean; } export function ServiceOverviewInstancesTable({ - primaryStatsItems = [], - primaryStatsItemCount, + mainStatsItems = [], + mainStatsItemCount, serviceName, - primaryStatsStatus: status, + mainStatsStatus: status, tableOptions, onChangeTableOptions, - comparisonStatsData: comparisonStatsData, + detailedStatsData: detailedStatsData, isLoading, }: Props) { const { agentName } = useApmServiceContext(); @@ -72,14 +72,14 @@ export function ServiceOverviewInstancesTable({ agentName, serviceName, latencyAggregationType, - comparisonStatsData, + detailedStatsData, comparisonEnabled, }); const pagination = { pageIndex, pageSize: PAGE_SIZE, - totalItemCount: primaryStatsItemCount, + totalItemCount: mainStatsItemCount, hidePerPageOptions: true, }; @@ -97,11 +97,11 @@ export function ServiceOverviewInstancesTable({ ; +type TransactionGroupMainStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/main_statistics'>; type ServiceTransactionGroupItem = ValuesType< - TransactionGroupPrimaryStatistics['transactionGroups'] + TransactionGroupMainStatistics['transactionGroups'] >; -type TransactionGroupComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics'>; +type TransactionGroupDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/detailed_statistics'>; export function getColumns({ serviceName, latencyAggregationType, - transactionGroupComparisonStatistics, + transactionGroupDetailedStatistics, comparisonEnabled, }: { serviceName: string; latencyAggregationType?: LatencyAggregationType; - transactionGroupComparisonStatistics?: TransactionGroupComparisonStatistics; + transactionGroupDetailedStatistics?: TransactionGroupDetailedStatistics; comparisonEnabled?: boolean; }): Array> { return [ @@ -74,9 +74,9 @@ export function getColumns({ width: px(unit * 10), render: (_, { latency, name }) => { const currentTimeseries = - transactionGroupComparisonStatistics?.currentPeriod?.[name]?.latency; + transactionGroupDetailedStatistics?.currentPeriod?.[name]?.latency; const previousTimeseries = - transactionGroupComparisonStatistics?.previousPeriod?.[name]?.latency; + transactionGroupDetailedStatistics?.previousPeriod?.[name]?.latency; return ( { const currentTimeseries = - transactionGroupComparisonStatistics?.currentPeriod?.[name] - ?.throughput; + transactionGroupDetailedStatistics?.currentPeriod?.[name]?.throughput; const previousTimeseries = - transactionGroupComparisonStatistics?.previousPeriod?.[name] + transactionGroupDetailedStatistics?.previousPeriod?.[name] ?.throughput; return ( { const currentTimeseries = - transactionGroupComparisonStatistics?.currentPeriod?.[name] - ?.errorRate; + transactionGroupDetailedStatistics?.currentPeriod?.[name]?.errorRate; const previousTimeseries = - transactionGroupComparisonStatistics?.previousPeriod?.[name] - ?.errorRate; + transactionGroupDetailedStatistics?.previousPeriod?.[name]?.errorRate; return ( { const currentImpact = - transactionGroupComparisonStatistics?.currentPeriod?.[name]?.impact ?? + transactionGroupDetailedStatistics?.currentPeriod?.[name]?.impact ?? 0; const previousImpact = - transactionGroupComparisonStatistics?.previousPeriod?.[name]?.impact; + transactionGroupDetailedStatistics?.previousPeriod?.[name]?.impact; return ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index 121b96b0361b2..0a4a735c117d2 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -29,7 +29,7 @@ interface Props { serviceName: string; } -type ApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics'>; +type ApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/main_statistics'>; const INITIAL_STATE = { transactionGroups: [] as ApiResponse['transactionGroups'], isAggregationAccurate: true, @@ -77,6 +77,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { start, end, comparisonType, + comparisonEnabled, }); const { data = INITIAL_STATE, status } = useFetcher( @@ -86,7 +87,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { } return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', + 'GET /api/apm/services/{serviceName}/transactions/groups/main_statistics', params: { path: { serviceName }, query: { @@ -107,14 +108,13 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { return { ...response, - // Everytime the primary statistics is refetched, updates the requestId making the comparison API to be refetched. + // Everytime the main statistics is refetched, updates the requestId making the detailed API to be refetched. requestId: uuid(), transactionGroupsTotalItems: response.transactionGroups.length, transactionGroups: currentPageTransactionGroups, }; }); }, - // comparisonType is listed as dependency even thought it is not used. This is needed to trigger the comparison api when it is changed. // eslint-disable-next-line react-hooks/exhaustive-deps [ environment, @@ -127,15 +127,18 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { pageIndex, direction, field, + // not used, but needed to trigger an update when comparisonType is changed either manually by user or when time range is changed comparisonType, + // not used, but needed to trigger an update when comparison feature is disabled/enabled by user + comparisonEnabled, ] ); const { transactionGroups, requestId, transactionGroupsTotalItems } = data; const { - data: transactionGroupComparisonStatistics, - status: transactionGroupComparisonStatisticsStatus, + data: transactionGroupDetailedStatistics, + status: transactionGroupDetailedStatisticsStatus, } = useFetcher( (callApmApi) => { if ( @@ -147,7 +150,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { ) { return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', + 'GET /api/apm/services/{serviceName}/transactions/groups/detailed_statistics', params: { path: { serviceName }, query: { @@ -168,7 +171,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { }); } }, - // only fetches comparison statistics when requestId is invalidated by primary statistics api call + // only fetches detailed statistics when requestId is invalidated by main statistics api call // eslint-disable-next-line react-hooks/exhaustive-deps [requestId], { preservePreviousData: false } @@ -177,13 +180,13 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { const columns = getColumns({ serviceName, latencyAggregationType, - transactionGroupComparisonStatistics, + transactionGroupDetailedStatistics, comparisonEnabled, }); const isLoading = status === FETCH_STATUS.LOADING || - transactionGroupComparisonStatisticsStatus === FETCH_STATUS.LOADING; + transactionGroupDetailedStatisticsStatus === FETCH_STATUS.LOADING; const pagination = { pageIndex, diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx index 5d6e46bb2ffd2..7f8ffb62d9e72 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx @@ -137,6 +137,19 @@ function LogsTabContent({ transaction }: { transaction: Transaction }) { endTimestamp={endTimestamp + framePaddingMs} query={`trace.id:"${transaction.trace.id}" OR "${transaction.trace.id}"`} height={640} + columns={[ + { type: 'timestamp' }, + { + type: 'field', + field: 'service.name', + header: i18n.translate( + 'xpack.apm.propertiesTable.tabs.logs.serviceName', + { defaultMessage: 'Service Name' } + ), + width: 200, + }, + { type: 'message' }, + ]} /> ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.tsx b/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.tsx new file mode 100644 index 0000000000000..2c086dbb17222 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { ValuesType } from 'utility-types'; +import { RectAnnotation } from '@elastic/charts'; +import { EuiTheme } from 'src/plugins/kibana_react/common'; +import { rgba } from 'polished'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; + +type Alert = ValuesType< + APIReturnType<'GET /api/apm/services/{serviceName}/alerts'>['alerts'] +>; + +function getAlertColor({ theme, ruleId }: { ruleId: string; theme: EuiTheme }) { + switch (ruleId) { + default: + return theme.eui.euiColorVis2; + } +} + +export function getAlertAnnotations({ + alerts, + theme, +}: { + alerts?: Alert[]; + theme: EuiTheme; +}) { + return alerts?.flatMap((alert) => { + const uuid = alert['kibana.rac.alert.uuid']!; + const start = new Date(alert['kibana.rac.alert.start']!).getTime(); + const end = start + alert['kibana.rac.alert.duration.us']! / 1000; + const color = getAlertColor({ ruleId: alert['rule.id']!, theme }); + + return [ + , + , + ]; + }); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx index 0e24c8e51c543..0eb5b0e84ff39 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx @@ -9,13 +9,13 @@ import { TooltipInfo } from '@elastic/charts'; import React, { ComponentType } from 'react'; import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; -import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; +import { MainStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; import { CustomTooltip } from './custom_tooltip'; function getLatencyFormatter(props: TooltipInfo) { const maxLatency = Math.max( ...props.values.map((value) => { - const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + const datum = (value.datum as unknown) as MainStatsServiceInstanceItem; return datum.latency ?? 0; }) ); diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx index 2280fa91a659c..027f764317e46 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx @@ -15,7 +15,7 @@ import { TimeFormatter, } from '../../../../../common/utils/formatters'; import { useTheme } from '../../../../hooks/use_theme'; -import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; +import { MainStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; const latencyLabel = i18n.translate( 'xpack.apm.instancesLatencyDistributionChartTooltipLatencyLabel', @@ -48,7 +48,7 @@ function SingleInstanceCustomTooltip({ }) { const value = values[0]; const { color } = value; - const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + const datum = (value.datum as unknown) as MainStatsServiceInstanceItem; const { latency, serviceNodeName, throughput } = datum; return ( @@ -119,7 +119,7 @@ function MultipleInstanceCustomTooltip({
{values.map((value) => { const { color } = value; - const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + const datum = (value.datum as unknown) as MainStatsServiceInstanceItem; const { latency, serviceNodeName, throughput } = datum; return (
diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx index 57ecbd4ca0b78..394d5b5410d41 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx @@ -30,7 +30,7 @@ import { } from '../../../../../common/utils/formatters'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; -import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; +import { MainStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; import * as urlHelpers from '../../Links/url_helpers'; import { ChartContainer } from '../chart_container'; import { getResponseTimeTickFormatter } from '../transaction_charts/helper'; @@ -38,7 +38,7 @@ import { CustomTooltip } from './custom_tooltip'; export interface InstancesLatencyDistributionChartProps { height: number; - items?: PrimaryStatsServiceInstanceItem[]; + items?: MainStatsServiceInstanceItem[]; status: FETCH_STATUS; } diff --git a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx index 3f61273729e64..a20f7325f9376 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx @@ -9,6 +9,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiSelect, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useHistory } from 'react-router-dom'; +import { AlertType } from '../../../../../common/alert_types'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; import { useLicenseContext } from '../../../../context/license/use_license_context'; @@ -58,6 +60,8 @@ export function LatencyChart({ height }: Props) { mlJobId, } = latencyChartsData; + const { alerts } = useApmServiceContext(); + const timeseries = [ currentPeriod, comparisonEnabled ? previousPeriod : undefined, @@ -121,6 +125,11 @@ export function LatencyChart({ height }: Props) { timeseries={timeseries} yLabelFormat={getResponseTimeTickFormatter(latencyFormatter)} anomalyTimeseries={anomalyTimeseries} + alerts={alerts.filter( + (alert) => + alert['rule.id'] === AlertType.TransactionDuration || + alert['rule.id'] === AlertType.TransactionDurationAnomaly + )} /> diff --git a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index f0faec4e99490..f8e01ea37d373 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -41,6 +41,8 @@ import { unit } from '../../../style/variables'; import { ChartContainer } from './chart_container'; import { onBrushEnd, isTimeseriesEmpty } from './helper/helper'; import { getLatencyChartSelector } from '../../../selectors/latency_chart_selectors'; +import { APMServiceAlert } from '../../../context/apm_service/apm_service_context'; +import { getAlertAnnotations } from './helper/get_alert_annotations'; interface Props { id: string; @@ -62,8 +64,8 @@ interface Props { typeof getLatencyChartSelector >['anomalyTimeseries']; customTheme?: Record; + alerts?: APMServiceAlert[]; } - export function TimeseriesChart({ id, height = unit * 16, @@ -76,6 +78,7 @@ export function TimeseriesChart({ yDomain, anomalyTimeseries, customTheme = {}, + alerts, }: Props) { const history = useHistory(); const { annotations } = useAnnotationsContext(); @@ -193,6 +196,10 @@ export function TimeseriesChart({ style={{ fill: anomalyTimeseries.scores.color }} /> )} + {getAlertAnnotations({ + alerts, + theme, + })} ); diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx index fd9435db57bfd..9aefa55aaaa36 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx @@ -9,6 +9,7 @@ import { EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useParams } from 'react-router-dom'; +import { AlertType } from '../../../../../common/alert_types'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { asPercent } from '../../../../../common/utils/formatters'; import { useFetcher } from '../../../../hooks/use_fetcher'; @@ -62,12 +63,13 @@ export function TransactionErrorRateChart({ comparisonType, }, } = useUrlParams(); - const { transactionType } = useApmServiceContext(); + const { transactionType, alerts } = useApmServiceContext(); const comparisonChartThem = getComparisonChartTheme(theme); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, end, comparisonType, + comparisonEnabled, }); const { data = INITIAL_STATE, status } = useFetcher( @@ -121,7 +123,7 @@ export function TransactionErrorRateChart({ { data: data.previousPeriod.transactionErrorRate, type: 'area', - color: theme.eui.euiColorLightestShade, + color: theme.eui.euiColorMediumShade, title: i18n.translate( 'xpack.apm.errorRate.chart.errorRate.previousPeriodLabel', { defaultMessage: 'Previous period' } @@ -149,6 +151,9 @@ export function TransactionErrorRateChart({ yLabelFormat={yLabelFormat} yDomain={{ min: 0, max: 1 }} customTheme={comparisonChartThem} + alerts={alerts.filter( + (alert) => alert['rule.id'] === AlertType.TransactionErrorRate + )} /> ); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts index 7234e94881ce7..77ae49bff7d84 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -11,12 +11,23 @@ import { describe('getTimeRangeComparison', () => { describe('return empty object', () => { + it('when comparison is disabled', () => { + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + start: undefined, + end, + comparisonType: TimeRangeComparisonType.DayBefore, + comparisonEnabled: false, + }); + expect(result).toEqual({}); + }); it('when start is not defined', () => { const end = '2021-01-28T15:00:00.000Z'; const result = getTimeRangeComparison({ start: undefined, end, comparisonType: TimeRangeComparisonType.DayBefore, + comparisonEnabled: true, }); expect(result).toEqual({}); }); @@ -27,6 +38,7 @@ describe('getTimeRangeComparison', () => { start, end: undefined, comparisonType: TimeRangeComparisonType.DayBefore, + comparisonEnabled: true, }); expect(result).toEqual({}); }); @@ -39,6 +51,7 @@ describe('getTimeRangeComparison', () => { const end = '2021-01-28T15:00:00.000Z'; const result = getTimeRangeComparison({ comparisonType: TimeRangeComparisonType.DayBefore, + comparisonEnabled: true, start, end, }); @@ -52,6 +65,7 @@ describe('getTimeRangeComparison', () => { const end = '2021-01-28T15:00:00.000Z'; const result = getTimeRangeComparison({ comparisonType: TimeRangeComparisonType.WeekBefore, + comparisonEnabled: true, start, end, }); @@ -67,6 +81,7 @@ describe('getTimeRangeComparison', () => { start, end, comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonEnabled: true, }); expect(result).toEqual({ comparisonStart: '2021-02-09T14:24:02.174Z', @@ -83,6 +98,7 @@ describe('getTimeRangeComparison', () => { const end = '2021-01-28T15:00:00.000Z'; const result = getTimeRangeComparison({ comparisonType: TimeRangeComparisonType.WeekBefore, + comparisonEnabled: true, start, end, }); @@ -98,6 +114,7 @@ describe('getTimeRangeComparison', () => { const end = '2021-01-18T15:00:00.000Z'; const result = getTimeRangeComparison({ comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonEnabled: true, start, end, }); @@ -110,6 +127,7 @@ describe('getTimeRangeComparison', () => { const end = '2021-01-31T15:00:00.000Z'; const result = getTimeRangeComparison({ comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonEnabled: true, start, end, }); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts index e436f65e85ad9..025e8c2a9935d 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts @@ -39,15 +39,17 @@ const oneDayInMilliseconds = moment.duration(1, 'day').asMilliseconds(); const oneWeekInMilliseconds = moment.duration(1, 'week').asMilliseconds(); export function getTimeRangeComparison({ + comparisonEnabled, comparisonType, start, end, }: { + comparisonEnabled?: boolean; comparisonType?: TimeRangeComparisonType; start?: string; end?: string; }) { - if (!comparisonType || !start || !end) { + if (!comparisonEnabled || !comparisonType || !start || !end) { return {}; } diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index 84a2dad278a9b..98fbd4f399d98 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -63,10 +63,12 @@ function getSelectOptions({ start, end, rangeTo, + comparisonEnabled, }: { start?: string; end?: string; rangeTo?: string; + comparisonEnabled?: boolean; }) { const momentStart = moment(start); const momentEnd = moment(end); @@ -112,6 +114,7 @@ function getSelectOptions({ comparisonType: TimeRangeComparisonType.PeriodBefore, start, end, + comparisonEnabled, }); const dateFormat = getDateFormat({ @@ -140,7 +143,12 @@ export function TimeComparison() { urlParams: { start, end, comparisonEnabled, comparisonType, rangeTo }, } = useUrlParams(); - const selectOptions = getSelectOptions({ start, end, rangeTo }); + const selectOptions = getSelectOptions({ + start, + end, + rangeTo, + comparisonEnabled, + }); // Sets default values if (comparisonEnabled === undefined || comparisonType === undefined) { diff --git a/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx index b52bf21a6be1e..175471e7ae817 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx @@ -8,7 +8,7 @@ import { AppMountParameters, CoreStart } from 'kibana/public'; import { createContext } from 'react'; import { ConfigSchema } from '../..'; -import { ApmPluginSetupDeps } from '../../plugin'; +import { ApmPluginSetupDeps, ApmRuleRegistry } from '../../plugin'; import { MapsStartApi } from '../../../../maps/public'; export interface ApmPluginContextValue { @@ -16,6 +16,7 @@ export interface ApmPluginContextValue { config: ConfigSchema; core: CoreStart; plugins: ApmPluginSetupDeps & { maps?: MapsStartApi }; + apmRuleRegistry: ApmRuleRegistry; } export const ApmPluginContext = createContext({} as ApmPluginContextValue); diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index 9a910787d5fe8..07da5ea7f6c1f 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -12,6 +12,7 @@ import { ConfigSchema } from '../..'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; import { createCallApmApi } from '../../services/rest/createCallApmApi'; import { MlUrlGenerator } from '../../../../ml/public'; +import { ApmRuleRegistry } from '../../plugin'; const uiSettings: Record = { [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [ @@ -76,6 +77,11 @@ const mockCore = { }, }; +const mockApmRuleRegistry = ({ + getTypeByRuleId: () => undefined, + registerType: () => undefined, +} as unknown) as ApmRuleRegistry; + const mockConfig: ConfigSchema = { serviceMapEnabled: true, ui: { @@ -96,6 +102,9 @@ const mockPlugin = { timefilter: { timefilter: { setTime: () => {}, getTime: () => ({}) } }, }, }, + observability: { + isAlertingExperienceEnabled: () => false, + }, }; const mockAppMountParameters = { @@ -107,6 +116,7 @@ export const mockApmPluginContextValue = { config: mockConfig, core: mockCore, plugins: mockPlugin, + apmRuleRegistry: mockApmRuleRegistry, }; export function MockApmPluginContextWrapper({ diff --git a/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx b/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx index c99995b982a56..54914580aefbd 100644 --- a/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx @@ -6,6 +6,7 @@ */ import React, { createContext, ReactNode } from 'react'; +import { ValuesType } from 'utility-types'; import { isRumAgentName } from '../../../common/agent_name'; import { TRANSACTION_PAGE_LOAD, @@ -15,12 +16,19 @@ import { useServiceTransactionTypesFetcher } from './use_service_transaction_typ import { useUrlParams } from '../url_params_context/use_url_params'; import { useServiceAgentNameFetcher } from './use_service_agent_name_fetcher'; import { IUrlParams } from '../url_params_context/types'; +import { APIReturnType } from '../../services/rest/createCallApmApi'; +import { useServiceAlertsFetcher } from './use_service_alerts_fetcher'; + +export type APMServiceAlert = ValuesType< + APIReturnType<'GET /api/apm/services/{serviceName}/alerts'>['alerts'] +>; export const APMServiceContext = createContext<{ agentName?: string; transactionType?: string; transactionTypes: string[]; -}>({ transactionTypes: [] }); + alerts: APMServiceAlert[]; +}>({ transactionTypes: [], alerts: [] }); export function ApmServiceContextProvider({ children, @@ -29,16 +37,25 @@ export function ApmServiceContextProvider({ }) { const { urlParams } = useUrlParams(); const { agentName } = useServiceAgentNameFetcher(); + const transactionTypes = useServiceTransactionTypesFetcher(); + const transactionType = getTransactionType({ urlParams, transactionTypes, agentName, }); + const { alerts } = useServiceAlertsFetcher(transactionType); + return ( ); diff --git a/x-pack/plugins/apm/public/context/apm_service/use_service_alerts_fetcher.tsx b/x-pack/plugins/apm/public/context/apm_service/use_service_alerts_fetcher.tsx new file mode 100644 index 0000000000000..b07e6562a2154 --- /dev/null +++ b/x-pack/plugins/apm/public/context/apm_service/use_service_alerts_fetcher.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useParams } from 'react-router-dom'; +import { useApmPluginContext } from '../apm_plugin/use_apm_plugin_context'; +import { useUrlParams } from '../url_params_context/use_url_params'; +import { useFetcher } from '../../hooks/use_fetcher'; +import type { APMServiceAlert } from './apm_service_context'; + +export function useServiceAlertsFetcher(transactionType?: string) { + const { + plugins: { observability }, + } = useApmPluginContext(); + + const { + urlParams: { start, end, environment }, + } = useUrlParams(); + const { serviceName } = useParams<{ serviceName?: string }>(); + + const experimentalAlertsEnabled = observability.isAlertingExperienceEnabled(); + + const fetcherStatus = useFetcher( + (callApmApi) => { + if ( + !start || + !end || + !serviceName || + !transactionType || + !experimentalAlertsEnabled + ) { + return; + } + + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/alerts', + params: { + path: { + serviceName, + }, + query: { + start, + end, + transactionType, + environment, + }, + }, + }).catch((error) => { + console.error(error); + return { + alerts: [] as APMServiceAlert[], + }; + }); + }, + [ + start, + end, + serviceName, + transactionType, + environment, + experimentalAlertsEnabled, + ] + ); + + const { data, ...rest } = fetcherStatus; + + return { + ...rest, + alerts: data?.alerts ?? [], + }; +} diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts index 16a82b1d4972b..0f1592ca2679f 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts @@ -27,6 +27,7 @@ export function useTransactionLatencyChartsFetcher() { transactionName, latencyAggregationType, comparisonType, + comparisonEnabled, }, } = useUrlParams(); @@ -34,6 +35,7 @@ export function useTransactionLatencyChartsFetcher() { start, end, comparisonType, + comparisonEnabled, }); const { data, error, status } = useFetcher( diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 391c54c1e2497..f7bbe647d8e37 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -5,13 +5,8 @@ * 2.0. */ -import { ConfigSchema } from '.'; -import { - FetchDataParams, - FormatterRuleRegistry, - HasDataParams, - ObservabilityPublicSetup, -} from '../../observability/public'; +import { i18n } from '@kbn/i18n'; +import type { ConfigSchema } from '.'; import { AppMountParameters, CoreSetup, @@ -20,28 +15,35 @@ import { Plugin, PluginInitializerContext, } from '../../../../src/core/public'; -import { +import type { DataPublicPluginSetup, DataPublicPluginStart, } from '../../../../src/plugins/data/public'; -import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; -import { +import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import type { PluginSetupContract as AlertingPluginPublicSetup, PluginStartContract as AlertingPluginPublicStart, } from '../../alerting/public'; -import { FeaturesPluginSetup } from '../../features/public'; -import { LicensingPluginSetup } from '../../licensing/public'; -import { +import type { FeaturesPluginSetup } from '../../features/public'; +import type { LicensingPluginSetup } from '../../licensing/public'; +import type { MapsStartApi } from '../../maps/public'; +import type { MlPluginSetup, MlPluginStart } from '../../ml/public'; +import type { + FetchDataParams, + HasDataParams, + ObservabilityPublicSetup, +} from '../../observability/public'; +import { FormatterRuleRegistry } from '../../observability/public'; +import type { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../triggers_actions_ui/public'; +import { apmRuleRegistrySettings } from '../common/rules/apm_rule_registry_settings'; +import type { APMRuleFieldMap } from '../common/rules/apm_rule_field_map'; +import { registerApmAlerts } from './components/alerting/register_apm_alerts'; import { featureCatalogueEntry } from './featureCatalogueEntry'; import { toggleAppLinkInNav } from './toggleAppLinkInNav'; -import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; -import { registerApmAlerts } from './components/alerting/register_apm_alerts'; -import { MlPluginSetup, MlPluginStart } from '../../ml/public'; -import { MapsStartApi } from '../../maps/public'; -import { apmRuleRegistrySettings } from '../common/rules'; export type ApmPluginSetup = ReturnType; export type ApmRuleRegistry = ApmPluginSetup['ruleRegistry']; @@ -85,54 +87,57 @@ export class ApmPlugin implements Plugin { pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); } - if (plugins.observability) { - const getApmDataHelper = async () => { - const { - fetchObservabilityOverviewPageData, - getHasData, - createCallApmApi, - } = await import('./services/rest/apm_observability_overview_fetchers'); - // have to do this here as well in case app isn't mounted yet - createCallApmApi(core); - - return { fetchObservabilityOverviewPageData, getHasData }; - }; - plugins.observability.dashboard.register({ - appName: 'apm', - hasData: async () => { - const dataHelper = await getApmDataHelper(); - return await dataHelper.getHasData(); - }, - fetchData: async (params: FetchDataParams) => { - const dataHelper = await getApmDataHelper(); - return await dataHelper.fetchObservabilityOverviewPageData(params); - }, - }); - - const getUxDataHelper = async () => { - const { - fetchUxOverviewDate, - hasRumData, - createCallApmApi, - } = await import('./components/app/RumDashboard/ux_overview_fetchers'); - // have to do this here as well in case app isn't mounted yet - createCallApmApi(core); - - return { fetchUxOverviewDate, hasRumData }; - }; - - plugins.observability.dashboard.register({ - appName: 'ux', - hasData: async (params?: HasDataParams) => { - const dataHelper = await getUxDataHelper(); - return await dataHelper.hasRumData(params!); - }, - fetchData: async (params: FetchDataParams) => { - const dataHelper = await getUxDataHelper(); - return await dataHelper.fetchUxOverviewDate(params); - }, - }); - } + const apmRuleRegistry = plugins.observability.ruleRegistry.create({ + ...apmRuleRegistrySettings, + fieldMap: {} as APMRuleFieldMap, + ctor: FormatterRuleRegistry, + }); + const getApmDataHelper = async () => { + const { + fetchObservabilityOverviewPageData, + getHasData, + createCallApmApi, + } = await import('./services/rest/apm_observability_overview_fetchers'); + // have to do this here as well in case app isn't mounted yet + createCallApmApi(core); + + return { fetchObservabilityOverviewPageData, getHasData }; + }; + plugins.observability.dashboard.register({ + appName: 'apm', + hasData: async () => { + const dataHelper = await getApmDataHelper(); + return await dataHelper.getHasData(); + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getApmDataHelper(); + return await dataHelper.fetchObservabilityOverviewPageData(params); + }, + }); + + const getUxDataHelper = async () => { + const { + fetchUxOverviewDate, + hasRumData, + createCallApmApi, + } = await import('./components/app/RumDashboard/ux_overview_fetchers'); + // have to do this here as well in case app isn't mounted yet + createCallApmApi(core); + + return { fetchUxOverviewDate, hasRumData }; + }; + + plugins.observability.dashboard.register({ + appName: 'ux', + hasData: async (params?: HasDataParams) => { + const dataHelper = await getUxDataHelper(); + return await dataHelper.hasRumData(params!); + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getUxDataHelper(); + return await dataHelper.fetchUxOverviewDate(params); + }, + }); core.application.register({ id: 'apm', @@ -142,29 +147,51 @@ export class ApmPlugin implements Plugin { appRoute: '/app/apm', icon: 'plugins/apm/public/icon.svg', category: DEFAULT_APP_CATEGORIES.observability, + meta: { + // !! Need to be kept in sync with the routes in x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx + searchDeepLinks: [ + { + id: 'services', + title: i18n.translate('xpack.apm.breadcrumb.servicesTitle', { + defaultMessage: 'Services', + }), + path: '/services', + }, + { + id: 'traces', + title: i18n.translate('xpack.apm.breadcrumb.tracesTitle', { + defaultMessage: 'Traces', + }), + path: '/traces', + }, + { + id: 'service-map', + title: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { + defaultMessage: 'Service Map', + }), + path: '/service-map', + }, + ], + }, - async mount(params: AppMountParameters) { + async mount(appMountParameters: AppMountParameters) { // Load application bundle and Get start services - const [{ renderApp }, [coreStart, corePlugins]] = await Promise.all([ + const [{ renderApp }, [coreStart, pluginsStart]] = await Promise.all([ import('./application'), core.getStartServices(), ]); - return renderApp( + return renderApp({ coreStart, - pluginSetupDeps, - params, + pluginsSetup: pluginSetupDeps, + appMountParameters, config, - corePlugins as ApmPluginStartDeps - ); + pluginsStart: pluginsStart as ApmPluginStartDeps, + apmRuleRegistry, + }); }, }); - const apmRuleRegistry = plugins.observability.ruleRegistry.create({ - ...apmRuleRegistrySettings, - ctor: FormatterRuleRegistry, - }); - registerApmAlerts(apmRuleRegistry); core.application.register({ @@ -191,20 +218,21 @@ export class ApmPlugin implements Plugin { 'web perf', ], }, - async mount(params: AppMountParameters) { + async mount(appMountParameters: AppMountParameters) { // Load application bundle and Get start service const [{ renderApp }, [coreStart, corePlugins]] = await Promise.all([ import('./application/csmApp'), core.getStartServices(), ]); - return renderApp( - coreStart, - pluginSetupDeps, - params, + return renderApp({ + core: coreStart, + deps: pluginSetupDeps, + appMountParameters, config, - corePlugins as ApmPluginStartDeps - ); + corePlugins: corePlugins as ApmPluginStartDeps, + apmRuleRegistry, + }); }, }); diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts index 252ced2be5e0e..808beb72f2e7a 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts @@ -18,7 +18,7 @@ const theme = { euiColorVis5: 'red', euiColorVis7: 'black', euiColorVis9: 'yellow', - euiColorLightestShade: 'green', + euiColorMediumShade: 'green', }, } as EuiTheme; diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts index 2ee4a717106eb..a84a4abfe5810 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts @@ -65,7 +65,7 @@ function getPreviousPeriodTimeseries({ return { data: previousPeriod.latencyTimeseries ?? [], type: 'area', - color: theme.eui.euiColorLightestShade, + color: theme.eui.euiColorMediumShade, title: i18n.translate( 'xpack.apm.serviceOverview.latencyChartTitle.previousPeriodLabel', { defaultMessage: 'Previous period' } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts index b9346b2bf4649..ad1a8fcbf6e55 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts @@ -5,7 +5,7 @@ * 2.0. */ import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; -import { ANOMALY_SEVERITY } from '../../../../ml/common'; +import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { Job, MlPluginSetup } from '../../../../ml/server'; import * as GetServiceAnomalies from '../service_map/get_service_anomalies'; import { createRuleTypeMocks } from './test_utils'; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 66eb7125b0370..67ff7cdb8e4e0 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -18,7 +18,7 @@ import { TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { asMutableArray } from '../../../common/utils/as_mutable_array'; -import { ANOMALY_SEVERITY } from '../../../../ml/common'; +import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AlertType, diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts index 565e437504ee5..0b1bc3d50d4c1 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts @@ -78,6 +78,7 @@ const apmPerAgentSchema: Pick< java: long, 'js-base': long, nodejs: long, + php: long, python: long, ruby: long, 'rum-js': long, @@ -99,6 +100,7 @@ const apmPerAgentSchema: Pick< java: agentSchema, 'js-base': agentSchema, nodejs: agentSchema, + php: agentSchema, python: agentSchema, ruby: agentSchema, 'rum-js': agentSchema, diff --git a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts index ec96b5225d617..8de2e4e1cca42 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts @@ -49,11 +49,16 @@ export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) { const response = await apmEventClient.search(params); return { + indices: setup.indices['apm_oss.transactionIndices']!, hasData: response.hits.total.value > 0, serviceName: response.aggregations?.services?.mostTraffic?.buckets?.[0]?.key, }; } catch (e) { - return { hasData: false, serviceName: undefined }; + return { + hasData: false, + serviceName: undefined, + indices: setup.indices['apm_oss.transactionIndices']!, + }; } } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_alerts.ts b/x-pack/plugins/apm/server/lib/services/get_service_alerts.ts new file mode 100644 index 0000000000000..6356731cc48d1 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_alerts.ts @@ -0,0 +1,80 @@ +/* + * 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. + */ + +import { + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import type { PromiseReturnType } from '../../../../observability/typings/common'; +import type { APMRuleRegistry } from '../../plugin'; +import { environmentQuery, rangeQuery } from '../../utils/queries'; + +export async function getServiceAlerts({ + apmRuleRegistryClient, + start, + end, + serviceName, + environment, + transactionType, +}: { + apmRuleRegistryClient: Exclude< + PromiseReturnType, + undefined + >; + start: number; + end: number; + serviceName: string; + environment?: string; + transactionType: string; +}) { + const response = await apmRuleRegistryClient.search({ + body: { + query: { + bool: { + filter: [ + ...rangeQuery(start, end), + ...environmentQuery(environment), + { term: { [SERVICE_NAME]: serviceName } }, + ], + should: [ + { + bool: { + filter: [ + { + term: { + [TRANSACTION_TYPE]: transactionType, + }, + }, + ], + }, + }, + { + bool: { + must_not: { + exists: { + field: TRANSACTION_TYPE, + }, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + size: 100, + fields: ['*'], + collapse: { + field: 'kibana.rac.alert.uuid', + }, + sort: { + '@timestamp': 'desc', + }, + }, + }); + + return response.events; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_comparison_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_detailed_statistics.ts similarity index 94% rename from x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_comparison_statistics.ts rename to x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_detailed_statistics.ts index b559f55bbe78e..dd41269f0bad6 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_comparison_statistics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_detailed_statistics.ts @@ -22,7 +22,7 @@ import { withApmSpan } from '../../../utils/with_apm_span'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -export async function getServiceErrorGroupComparisonStatistics({ +export async function getServiceErrorGroupDetailedStatistics({ kuery, serviceName, setup, @@ -44,7 +44,7 @@ export async function getServiceErrorGroupComparisonStatistics({ end: number; }): Promise> { return withApmSpan( - 'get_service_error_group_comparison_statistics', + 'get_service_error_group_detailed_statistics', async () => { const { apmEventClient } = setup; @@ -147,7 +147,7 @@ export async function getServiceErrorGroupPeriods({ groupIds, }; - const currentPeriodPromise = getServiceErrorGroupComparisonStatistics({ + const currentPeriodPromise = getServiceErrorGroupDetailedStatistics({ ...commonProps, start, end, @@ -155,7 +155,7 @@ export async function getServiceErrorGroupPeriods({ const previousPeriodPromise = comparisonStart && comparisonEnd - ? getServiceErrorGroupComparisonStatistics({ + ? getServiceErrorGroupDetailedStatistics({ ...commonProps, start: comparisonStart, end: comparisonEnd, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_primary_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_main_statistics.ts similarity index 95% rename from x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_primary_statistics.ts rename to x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_main_statistics.ts index 13a6069876369..361c92244aee0 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_primary_statistics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/get_service_error_group_main_statistics.ts @@ -23,7 +23,7 @@ import { withApmSpan } from '../../../utils/with_apm_span'; import { getErrorName } from '../../helpers/get_error_name'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -export function getServiceErrorGroupPrimaryStatistics({ +export function getServiceErrorGroupMainStatistics({ kuery, serviceName, setup, @@ -36,7 +36,7 @@ export function getServiceErrorGroupPrimaryStatistics({ transactionType: string; environment?: string; }) { - return withApmSpan('get_service_error_group_primary_statistics', async () => { + return withApmSpan('get_service_error_group_main_statistics', async () => { const { apmEventClient, start, end } = setup; const response = await apmEventClient.search({ diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/comparison_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/detailed_statistics.ts similarity index 80% rename from x-pack/plugins/apm/server/lib/services/get_service_instances/comparison_statistics.ts rename to x-pack/plugins/apm/server/lib/services/get_service_instances/detailed_statistics.ts index 6fca42723b9cc..85414100a1563 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/comparison_statistics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/detailed_statistics.ts @@ -15,7 +15,7 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getServiceInstancesSystemMetricStatistics } from './get_service_instances_system_metric_statistics'; import { getServiceInstancesTransactionStatistics } from './get_service_instances_transaction_statistics'; -interface ServiceInstanceComparisonStatisticsParams { +interface ServiceInstanceDetailedStatisticsParams { environment?: string; kuery?: string; latencyAggregationType: LatencyAggregationType; @@ -29,8 +29,8 @@ interface ServiceInstanceComparisonStatisticsParams { serviceNodeIds: string[]; } -async function getServiceInstancesComparisonStatistics( - params: ServiceInstanceComparisonStatisticsParams +async function getServiceInstancesDetailedStatistics( + params: ServiceInstanceDetailedStatisticsParams ): Promise< Array<{ serviceNodeName: string; @@ -41,31 +41,28 @@ async function getServiceInstancesComparisonStatistics( memoryUsage?: Coordinate[]; }> > { - return withApmSpan( - 'get_service_instances_comparison_statistics', - async () => { - const [transactionStats, systemMetricStats = []] = await Promise.all([ - getServiceInstancesTransactionStatistics({ - ...params, - isComparisonSearch: true, - }), - getServiceInstancesSystemMetricStatistics({ - ...params, - isComparisonSearch: true, - }), - ]); + return withApmSpan('get_service_instances_detailed_statistics', async () => { + const [transactionStats, systemMetricStats = []] = await Promise.all([ + getServiceInstancesTransactionStatistics({ + ...params, + isComparisonSearch: true, + }), + getServiceInstancesSystemMetricStatistics({ + ...params, + isComparisonSearch: true, + }), + ]); - const stats = joinByKey( - [...transactionStats, ...systemMetricStats], - 'serviceNodeName' - ); + const stats = joinByKey( + [...transactionStats, ...systemMetricStats], + 'serviceNodeName' + ); - return stats; - } - ); + return stats; + }); } -export async function getServiceInstancesComparisonStatisticsPeriods({ +export async function getServiceInstancesDetailedStatisticsPeriods({ environment, kuery, latencyAggregationType, @@ -91,7 +88,7 @@ export async function getServiceInstancesComparisonStatisticsPeriods({ comparisonEnd?: number; }) { return withApmSpan( - 'get_service_instances_comparison_statistics_periods', + 'get_service_instances_detailed_statistics_periods', async () => { const { start, end } = setup; @@ -107,7 +104,7 @@ export async function getServiceInstancesComparisonStatisticsPeriods({ serviceNodeIds, }; - const currentPeriodPromise = getServiceInstancesComparisonStatistics({ + const currentPeriodPromise = getServiceInstancesDetailedStatistics({ ...commonParams, start, end, @@ -115,7 +112,7 @@ export async function getServiceInstancesComparisonStatisticsPeriods({ const previousPeriodPromise = comparisonStart && comparisonEnd - ? getServiceInstancesComparisonStatistics({ + ? getServiceInstancesDetailedStatistics({ ...commonParams, start: comparisonStart, end: comparisonEnd, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/primary_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/main_statistics.ts similarity index 87% rename from x-pack/plugins/apm/server/lib/services/get_service_instances/primary_statistics.ts rename to x-pack/plugins/apm/server/lib/services/get_service_instances/main_statistics.ts index 3cd98558eff02..8bfa67f8c6247 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/primary_statistics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/main_statistics.ts @@ -12,7 +12,7 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getServiceInstancesSystemMetricStatistics } from './get_service_instances_system_metric_statistics'; import { getServiceInstancesTransactionStatistics } from './get_service_instances_transaction_statistics'; -interface ServiceInstancePrimaryStatisticsParams { +interface ServiceInstanceMainStatisticsParams { environment?: string; kuery?: string; latencyAggregationType: LatencyAggregationType; @@ -25,8 +25,8 @@ interface ServiceInstancePrimaryStatisticsParams { end: number; } -export async function getServiceInstancesPrimaryStatistics( - params: Omit +export async function getServiceInstancesMainStatistics( + params: Omit ): Promise< Array<{ serviceNodeName: string; @@ -37,7 +37,7 @@ export async function getServiceInstancesPrimaryStatistics( memoryUsage?: number | null; }> > { - return withApmSpan('get_service_instances_primary_statistics', async () => { + return withApmSpan('get_service_instances_main_statistics', async () => { const paramsForSubQueries = { ...params, size: 50, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_detailed_statistics.ts similarity index 95% rename from x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts rename to x-pack/plugins/apm/server/lib/services/get_service_transaction_group_detailed_statistics.ts index 54e882d1dd6da..314d6c7bd1458 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_detailed_statistics.ts @@ -35,7 +35,7 @@ import { import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { calculateTransactionErrorPercentage } from '../helpers/transaction_error_rate'; -export async function getServiceTransactionGroupComparisonStatistics({ +export async function getServiceTransactionGroupDetailedStatistics({ environment, kuery, serviceName, @@ -69,7 +69,7 @@ export async function getServiceTransactionGroupComparisonStatistics({ }> > { return withApmSpan( - 'get_service_transaction_group_comparison_statistics', + 'get_service_transaction_group_detailed_statistics', async () => { const { apmEventClient } = setup; const { intervalString } = getBucketSize({ start, end, numBuckets }); @@ -185,7 +185,7 @@ export async function getServiceTransactionGroupComparisonStatistics({ ); } -export async function getServiceTransactionGroupComparisonStatisticsPeriods({ +export async function getServiceTransactionGroupDetailedStatisticsPeriods({ serviceName, transactionNames, setup, @@ -224,7 +224,7 @@ export async function getServiceTransactionGroupComparisonStatisticsPeriods({ kuery, }; - const currentPeriodPromise = getServiceTransactionGroupComparisonStatistics({ + const currentPeriodPromise = getServiceTransactionGroupDetailedStatistics({ ...commonProps, start, end, @@ -232,7 +232,7 @@ export async function getServiceTransactionGroupComparisonStatisticsPeriods({ const previousPeriodPromise = comparisonStart && comparisonEnd - ? getServiceTransactionGroupComparisonStatistics({ + ? getServiceTransactionGroupDetailedStatistics({ ...commonProps, start: comparisonStart, end: comparisonEnd, diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts index a03b1ac82e90a..bcd279c57f4a5 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts @@ -14,7 +14,7 @@ import { getBucketSize } from '../../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { anomalySeriesFetcher } from './fetcher'; import { getMLJobIds } from '../../service_map/get_service_anomalies'; -import { ANOMALY_THRESHOLD } from '../../../../../ml/common'; +import { ANOMALY_THRESHOLD } from '../../../../common/ml_constants'; import { withApmSpan } from '../../../utils/with_apm_span'; export async function getAnomalySeries({ diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 714b887a4008b..e12d089855834 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -42,7 +42,8 @@ import { } from './types'; import { registerRoutes } from './routes/register_routes'; import { getGlobalApmServerRouteRepository } from './routes/get_global_apm_server_route_repository'; -import { apmRuleRegistrySettings } from '../common/rules'; +import { apmRuleRegistrySettings } from '../common/rules/apm_rule_registry_settings'; +import { apmRuleFieldMap } from '../common/rules/apm_rule_field_map'; export type APMRuleRegistry = ReturnType['ruleRegistry']; @@ -123,6 +124,11 @@ export class APMPlugin registerFeaturesUsage({ licensingPlugin: plugins.licensing }); + const apmRuleRegistry = plugins.observability.ruleRegistry.create({ + ...apmRuleRegistrySettings, + fieldMap: apmRuleFieldMap, + }); + registerRoutes({ core: { setup: core, @@ -131,6 +137,7 @@ export class APMPlugin logger: this.logger, config: currentConfig, repository: getGlobalApmServerRouteRepository(), + apmRuleRegistry, plugins: mapValues(plugins, (value, key) => { return { setup: value, @@ -150,11 +157,6 @@ export class APMPlugin savedObjectsClient: await getInternalSavedObjectsClient(core), config: await mergedConfig$.pipe(take(1)).toPromise(), }); - - const apmRuleRegistry = plugins.observability.ruleRegistry.create( - apmRuleRegistrySettings - ); - registerApmAlerts({ registry: apmRuleRegistry, ml: plugins.ml, diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.ts b/x-pack/plugins/apm/server/routes/register_routes/index.ts index 3a88a496b923f..f792e078c528a 100644 --- a/x-pack/plugins/apm/server/routes/register_routes/index.ts +++ b/x-pack/plugins/apm/server/routes/register_routes/index.ts @@ -39,12 +39,14 @@ export function registerRoutes({ plugins, logger, config, + apmRuleRegistry, }: { core: APMRouteHandlerResources['core']; plugins: APMRouteHandlerResources['plugins']; logger: APMRouteHandlerResources['logger']; repository: ServerRouteRepository; config: APMRouteHandlerResources['config']; + apmRuleRegistry: APMRouteHandlerResources['apmRuleRegistry']; }) { const routes = repository.getRoutes(); @@ -97,6 +99,7 @@ export function registerRoutes({ }, validatedParams ), + apmRuleRegistry, })) as any; if (Array.isArray(data)) { diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 3ac76d4a5b4c2..30aa4cce45d04 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -16,11 +16,12 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAnnotations } from '../lib/services/annotations'; import { getServices } from '../lib/services/get_services'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; +import { getServiceAlerts } from '../lib/services/get_service_alerts'; import { getServiceDependencies } from '../lib/services/get_service_dependencies'; -import { getServiceErrorGroupPeriods } from '../lib/services/get_service_error_groups/get_service_error_group_comparison_statistics'; -import { getServiceErrorGroupPrimaryStatistics } from '../lib/services/get_service_error_groups/get_service_error_group_primary_statistics'; -import { getServiceInstancesComparisonStatisticsPeriods } from '../lib/services/get_service_instances/comparison_statistics'; -import { getServiceInstancesPrimaryStatistics } from '../lib/services/get_service_instances/primary_statistics'; +import { getServiceErrorGroupPeriods } from '../lib/services/get_service_error_groups/get_service_error_group_detailed_statistics'; +import { getServiceErrorGroupMainStatistics } from '../lib/services/get_service_error_groups/get_service_error_group_main_statistics'; +import { getServiceInstancesDetailedStatisticsPeriods } from '../lib/services/get_service_instances/detailed_statistics'; +import { getServiceInstancesMainStatistics } from '../lib/services/get_service_instances/main_statistics'; import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details'; import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons'; import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; @@ -292,9 +293,8 @@ const serviceAnnotationsCreateRoute = createApmServerRoute({ }, }); -const serviceErrorGroupsPrimaryStatisticsRoute = createApmServerRoute({ - endpoint: - 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics', +const serviceErrorGroupsMainStatisticsRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/error_groups/main_statistics', params: t.type({ path: t.type({ serviceName: t.string, @@ -317,7 +317,7 @@ const serviceErrorGroupsPrimaryStatisticsRoute = createApmServerRoute({ path: { serviceName }, query: { kuery, transactionType, environment }, } = params; - return getServiceErrorGroupPrimaryStatistics({ + return getServiceErrorGroupMainStatistics({ kuery, serviceName, setup, @@ -327,9 +327,9 @@ const serviceErrorGroupsPrimaryStatisticsRoute = createApmServerRoute({ }, }); -const serviceErrorGroupsComparisonStatisticsRoute = createApmServerRoute({ +const serviceErrorGroupsDetailedStatisticsRoute = createApmServerRoute({ endpoint: - 'GET /api/apm/services/{serviceName}/error_groups/comparison_statistics', + 'GET /api/apm/services/{serviceName}/error_groups/detailed_statistics', params: t.type({ path: t.type({ serviceName: t.string, @@ -444,9 +444,9 @@ const serviceThroughputRoute = createApmServerRoute({ }, }); -const serviceInstancesPrimaryStatisticsRoute = createApmServerRoute({ +const serviceInstancesMainStatisticsRoute = createApmServerRoute({ endpoint: - 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics', + 'GET /api/apm/services/{serviceName}/service_overview_instances/main_statistics', params: t.type({ path: t.type({ serviceName: t.string, @@ -479,7 +479,7 @@ const serviceInstancesPrimaryStatisticsRoute = createApmServerRoute({ const { start, end } = setup; - const serviceInstances = await getServiceInstancesPrimaryStatistics({ + const serviceInstances = await getServiceInstancesMainStatistics({ environment, kuery, latencyAggregationType, @@ -495,9 +495,9 @@ const serviceInstancesPrimaryStatisticsRoute = createApmServerRoute({ }, }); -const serviceInstancesComparisonStatisticsRoute = createApmServerRoute({ +const serviceInstancesDetailedStatisticsRoute = createApmServerRoute({ endpoint: - 'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics', + 'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics', params: t.type({ path: t.type({ serviceName: t.string, @@ -535,7 +535,7 @@ const serviceInstancesComparisonStatisticsRoute = createApmServerRoute({ setup ); - return getServiceInstancesComparisonStatisticsPeriods({ + return getServiceInstancesDetailedStatisticsPeriods({ environment, kuery, latencyAggregationType, @@ -662,6 +662,57 @@ const serviceProfilingStatisticsRoute = createApmServerRoute({ }, }); +const serviceAlertsRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/alerts', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + environmentRt, + t.type({ + transactionType: t.string, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, params, apmRuleRegistry }) => { + const alertsClient = context.alerting.getAlertsClient(); + + const { + query: { start, end, environment, transactionType }, + path: { serviceName }, + } = params; + + const apmRuleRegistryClient = await apmRuleRegistry.createScopedRuleRegistryClient( + { + alertsClient, + context, + } + ); + + if (!apmRuleRegistryClient) { + throw Boom.failedDependency( + 'xpack.ruleRegistry.unsafe.write.enabled is set to false' + ); + } + + return { + alerts: await getServiceAlerts({ + apmRuleRegistryClient, + start, + end, + serviceName, + environment, + transactionType, + }), + }; + }, +}); + export const serviceRouteRepository = createApmServerRouteRepository() .add(servicesRoute) .add(serviceMetadataDetailsRoute) @@ -671,11 +722,12 @@ export const serviceRouteRepository = createApmServerRouteRepository() .add(serviceNodeMetadataRoute) .add(serviceAnnotationsRoute) .add(serviceAnnotationsCreateRoute) - .add(serviceErrorGroupsPrimaryStatisticsRoute) - .add(serviceErrorGroupsComparisonStatisticsRoute) + .add(serviceErrorGroupsMainStatisticsRoute) + .add(serviceErrorGroupsDetailedStatisticsRoute) .add(serviceThroughputRoute) - .add(serviceInstancesPrimaryStatisticsRoute) - .add(serviceInstancesComparisonStatisticsRoute) + .add(serviceInstancesMainStatisticsRoute) + .add(serviceInstancesDetailedStatisticsRoute) .add(serviceDependenciesRoute) .add(serviceProfilingTimelineRoute) - .add(serviceProfilingStatisticsRoute); + .add(serviceProfilingStatisticsRoute) + .add(serviceAlertsRoute); diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index b323801430dba..bcc554e552fc3 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -15,7 +15,7 @@ import { import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups'; -import { getServiceTransactionGroupComparisonStatisticsPeriods } from '../lib/services/get_service_transaction_group_comparison_statistics'; +import { getServiceTransactionGroupDetailedStatisticsPeriods } from '../lib/services/get_service_transaction_group_detailed_statistics'; import { getTransactionBreakdown } from '../lib/transactions/breakdown'; import { getTransactionDistribution } from '../lib/transactions/distribution'; import { getAnomalySeries } from '../lib/transactions/get_anomaly_data'; @@ -34,7 +34,7 @@ import { /** * Returns a list of transactions grouped by name - * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/primary_statistics/ + * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/main_statistics/ */ const transactionGroupsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups', @@ -74,9 +74,9 @@ const transactionGroupsRoute = createApmServerRoute({ }, }); -const transactionGroupsPrimaryStatisticsRoute = createApmServerRoute({ +const transactionGroupsMainStatisticsRoute = createApmServerRoute({ endpoint: - 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', + 'GET /api/apm/services/{serviceName}/transactions/groups/main_statistics', params: t.type({ path: t.type({ serviceName: t.string }), query: t.intersection([ @@ -117,9 +117,9 @@ const transactionGroupsPrimaryStatisticsRoute = createApmServerRoute({ }, }); -const transactionGroupsComparisonStatisticsRoute = createApmServerRoute({ +const transactionGroupsDetailedStatisticsRoute = createApmServerRoute({ endpoint: - 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', + 'GET /api/apm/services/{serviceName}/transactions/groups/detailed_statistics', params: t.type({ path: t.type({ serviceName: t.string }), query: t.intersection([ @@ -161,7 +161,7 @@ const transactionGroupsComparisonStatisticsRoute = createApmServerRoute({ }, } = params; - return await getServiceTransactionGroupComparisonStatisticsPeriods({ + return await getServiceTransactionGroupDetailedStatisticsPeriods({ environment, kuery, setup, @@ -431,8 +431,8 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ export const transactionRouteRepository = createApmServerRouteRepository() .add(transactionGroupsRoute) - .add(transactionGroupsPrimaryStatisticsRoute) - .add(transactionGroupsComparisonStatisticsRoute) + .add(transactionGroupsMainStatisticsRoute) + .add(transactionGroupsDetailedStatisticsRoute) .add(transactionLatencyChartsRoute) .add(transactionThroughputChartsRoute) .add(transactionChartsDistributionRoute) diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 517387c5f74ef..602e1f3e0edb9 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -12,12 +12,15 @@ import { KibanaRequest, CoreStart, } from 'src/core/server'; +import { AlertingApiRequestHandlerContext } from '../../../alerting/server'; import { LicensingApiRequestHandlerContext } from '../../../licensing/server'; import { APMConfig } from '..'; import { APMPluginDependencies } from '../types'; +import { APMRuleRegistry } from '../plugin'; export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { licensing: LicensingApiRequestHandlerContext; + alerting: AlertingApiRequestHandlerContext; } export type InspectResponse = Array<{ @@ -59,4 +62,5 @@ export interface APMRouteHandlerResources { start: () => Promise[key]['start']>; }; }; + apmRuleRegistry: APMRuleRegistry; } diff --git a/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts b/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts index 29f11e638f195..6bc18ed8b1575 100644 --- a/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts +++ b/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts @@ -13,7 +13,8 @@ export type ElasticAgentName = | 'nodejs' | 'python' | 'dotnet' - | 'ruby'; + | 'ruby' + | 'php'; export type OpenTelemetryAgentName = | 'otlp' diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index f910aff9a83fe..154beb6faa7b0 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -24,7 +24,6 @@ import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/publ import { registerLanguage } from './lib/monaco_language_def'; import { SetupRegistries } from './plugin_api'; import { initRegistries, populateRegistries, destroyRegistries } from './registries'; -import { getDocumentationLinks } from './lib/documentation_links'; import { HelpMenu } from './components/help_menu/help_menu'; import { createStore } from './store'; @@ -127,6 +126,8 @@ export const initializeCanvas = async ( } ); + // Setup documentation links + const { docLinks } = coreStart; // Set help extensions coreStart.chrome.setHelpExtension({ appName: i18n.translate('xpack.canvas.helpMenu.appName', { @@ -135,7 +136,7 @@ export const initializeCanvas = async ( links: [ { linkType: 'documentation', - href: getDocumentationLinks().canvas, + href: docLinks.links.canvas.guide, }, ], content: (domNode) => { diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot index 2a65ea4fd0f5f..dbb78a1b99f20 100644 --- a/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot @@ -185,16 +185,15 @@ exports[`Storyshots components/WorkpadTemplates default 1`] = ` Template name - - Click to sort in descending order - + + + Click to sort in descending order diff --git a/x-pack/plugins/canvas/public/lib/documentation_links.ts b/x-pack/plugins/canvas/public/lib/documentation_links.ts deleted file mode 100644 index e76ab6d1db54b..0000000000000 --- a/x-pack/plugins/canvas/public/lib/documentation_links.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { platformService } from '../services'; - -export const getDocumentationLinks = () => ({ - canvas: `${platformService - .getService() - .getElasticWebsiteUrl()}guide/en/kibana/${platformService - .getService() - .getDocLinkVersion()}/canvas.html`, - numeral: `${platformService - .getService() - .getElasticWebsiteUrl()}guide/en/kibana/${platformService - .getService() - .getDocLinkVersion()}/guide/numeral.html`, -}); diff --git a/x-pack/plugins/canvas/server/collectors/custom_element_collector.ts b/x-pack/plugins/canvas/server/collectors/custom_element_collector.ts index 144d77df064c7..18cfe1a3df56c 100644 --- a/x-pack/plugins/canvas/server/collectors/custom_element_collector.ts +++ b/x-pack/plugins/canvas/server/collectors/custom_element_collector.ts @@ -34,13 +34,41 @@ export interface CustomElementTelemetry { export const customElementSchema: MakeSchemaFrom = { custom_elements: { - count: { type: 'long' }, + count: { + type: 'long', + _meta: { + description: 'The total number of custom Canvas elements', + }, + }, elements: { - min: { type: 'long' }, - max: { type: 'long' }, - avg: { type: 'float' }, + min: { + type: 'long', + _meta: { + description: 'The minimum number of elements used across all Canvas Custom Elements', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum number of elements used across all Canvas Custom Elements', + }, + }, + avg: { + type: 'float', + _meta: { + description: 'The average number of elements used in Canvas Custom Element', + }, + }, + }, + functions_in_use: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'The functions in use by Canvas Custom Elements', + }, + }, }, - functions_in_use: { type: 'array', items: { type: 'keyword' } }, }, }; diff --git a/x-pack/plugins/canvas/server/collectors/workpad_collector.test.ts b/x-pack/plugins/canvas/server/collectors/workpad_collector.test.ts index 0e132047b2bbd..a82a0d45fa896 100644 --- a/x-pack/plugins/canvas/server/collectors/workpad_collector.test.ts +++ b/x-pack/plugins/canvas/server/collectors/workpad_collector.test.ts @@ -8,6 +8,7 @@ import { cloneDeep } from 'lodash'; import { summarizeWorkpads } from './workpad_collector'; import { workpads } from '../../__fixtures__/workpads'; +import moment from 'moment'; describe('usage collector handle es response data', () => { it('should summarize workpads, pages, and elements', () => { @@ -49,6 +50,8 @@ describe('usage collector handle es response data', () => { 'image', 'shape', ], + in_use_30d: [], + in_use_90d: [], }, variables: { total: 7, @@ -71,7 +74,13 @@ describe('usage collector handle es response data', () => { workpads: { total: 1 }, pages: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } }, elements: { total: 1, per_page: { avg: 1, min: 1, max: 1 } }, - functions: { total: 1, in_use: ['toast'], per_element: { avg: 1, min: 1, max: 1 } }, + functions: { + total: 1, + in_use: ['toast'], + in_use_30d: [], + in_use_90d: [], + per_element: { avg: 1, min: 1, max: 1 }, + }, variables: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } }, }); }); @@ -116,6 +125,8 @@ describe('usage collector handle es response data', () => { 'plot', 'seriesStyle', ], + in_use_30d: [], + in_use_90d: [], per_element: { avg: 7, min: 7, max: 7 }, }, variables: { total: 0, per_workpad: { avg: 0, min: 0, max: 0 } }, // Variables still possible even with no pages @@ -126,4 +137,42 @@ describe('usage collector handle es response data', () => { const usage = summarizeWorkpads([]); expect(usage).toEqual({}); }); + + describe('functions', () => { + it('collects funtions used in the most recent 30d and 90d', () => { + const thirtyDayFunction = '30d'; + const ninetyDayFunction = '90d'; + const otherFunction = '180d'; + + const workpad30d = cloneDeep(workpads[0]); + const workpad90d = cloneDeep(workpads[0]); + const workpad180d = cloneDeep(workpads[0]); + + const now = moment(); + + workpad30d['@timestamp'] = now.subtract(1, 'day').toDate().toISOString(); + workpad90d['@timestamp'] = now.subtract(80, 'day').toDate().toISOString(); + workpad180d['@timestamp'] = now.subtract(180, 'day').toDate().toISOString(); + + workpad30d.pages[0].elements[0].expression = `${thirtyDayFunction}`; + workpad90d.pages[0].elements[0].expression = `${ninetyDayFunction}`; + workpad180d.pages[0].elements[0].expression = `${otherFunction}`; + + const mockWorkpads = [workpad30d, workpad90d, workpad180d]; + const usage = summarizeWorkpads(mockWorkpads); + + expect(usage.functions?.in_use_30d).toHaveLength(1); + expect(usage.functions?.in_use_30d).toEqual(expect.arrayContaining([thirtyDayFunction])); + + expect(usage.functions?.in_use_90d).toHaveLength(2); + expect(usage.functions?.in_use_90d).toEqual( + expect.arrayContaining([thirtyDayFunction, ninetyDayFunction]) + ); + + expect(usage.functions?.in_use).toHaveLength(3); + expect(usage.functions?.in_use).toEqual( + expect.arrayContaining([thirtyDayFunction, ninetyDayFunction, otherFunction]) + ); + }); + }); }); diff --git a/x-pack/plugins/canvas/server/collectors/workpad_collector.ts b/x-pack/plugins/canvas/server/collectors/workpad_collector.ts index 7342cb5d40357..427c8c8a6571f 100644 --- a/x-pack/plugins/canvas/server/collectors/workpad_collector.ts +++ b/x-pack/plugins/canvas/server/collectors/workpad_collector.ts @@ -6,6 +6,7 @@ */ import { sum as arraySum, min as arrayMin, max as arrayMax, get } from 'lodash'; +import moment from 'moment'; import { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; import { CANVAS_TYPE } from '../../common/lib/constants'; import { collectFns } from './collector_helpers'; @@ -39,6 +40,8 @@ export interface WorkpadTelemetry { functions?: { total: number; in_use: string[]; + in_use_30d: string[]; + in_use_90d: string[]; per_element: { avg: number; min: number; @@ -56,38 +59,156 @@ export interface WorkpadTelemetry { } export const workpadSchema: MakeSchemaFrom = { - workpads: { total: { type: 'long' } }, + workpads: { + total: { + type: 'long', + _meta: { + description: 'The total number of Canvas Workpads in the cluster', + }, + }, + }, pages: { - total: { type: 'long' }, + total: { + type: 'long', + _meta: { + description: 'The total number of pages across all Canvas Workpads', + }, + }, per_workpad: { - avg: { type: 'float' }, - min: { type: 'long' }, - max: { type: 'long' }, + avg: { + type: 'float', + _meta: { + description: 'The average number of pages across all Canvas Workpads', + }, + }, + min: { + type: 'long', + _meta: { + description: 'The minimum number of pages found in a Canvas Workpad', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum number of pages found in a Canvas Workpad', + }, + }, }, }, elements: { - total: { type: 'long' }, + total: { + type: 'long', + _meta: { + description: 'The total number of elements across all Canvas Workpads', + }, + }, per_page: { - avg: { type: 'float' }, - min: { type: 'long' }, - max: { type: 'long' }, + avg: { + type: 'float', + _meta: { + description: 'The average number of elements per page across all Canvas Workpads', + }, + }, + min: { + type: 'long', + _meta: { + description: 'The minimum number of elements on a page across all Canvas Workpads', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum number of elements on a page across all Canvas Workpads', + }, + }, }, }, functions: { - total: { type: 'long' }, - in_use: { type: 'array', items: { type: 'keyword' } }, + total: { + type: 'long', + _meta: { + description: 'The total number of functions in use across all Canvas Workpads', + }, + }, + in_use: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'A function in use in any Canvas Workpad', + }, + }, + }, + in_use_30d: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: + 'A function in use in a Canvas Workpad that has been modified in the last 30 days', + }, + }, + }, + in_use_90d: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: + 'A function in use in a Canvas Workpad that has been modified in the last 90 days', + }, + }, + }, per_element: { - avg: { type: 'float' }, - min: { type: 'long' }, - max: { type: 'long' }, + avg: { + type: 'float', + _meta: { + description: 'Average number of functions used per element across all Canvas Workpads', + }, + }, + min: { + type: 'long', + _meta: { + description: + 'The minimum number of functions used in an element across all Canvas Workpads', + }, + }, + max: { + type: 'long', + _meta: { + description: + 'The maximum number of functions used in an element across all Canvas Workpads', + }, + }, }, }, variables: { - total: { type: 'long' }, + total: { + type: 'long', + _meta: { + description: 'The total number of variables defined across all Canvas Workpads', + }, + }, + per_workpad: { - avg: { type: 'float' }, - min: { type: 'long' }, - max: { type: 'long' }, + avg: { + type: 'float', + _meta: { + description: 'The average number of variables set per Canvas Workpad', + }, + }, + min: { + type: 'long', + _meta: { + description: 'The minimum number variables set across all Canvas Workpads', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum number of variables set across all Canvas Workpads', + }, + }, }, }, }; @@ -98,6 +219,11 @@ export const workpadSchema: MakeSchemaFrom = { @returns Workpad Telemetry Data */ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetry { + const functionCollection = { + all: new Set(), + '30d': new Set(), + '90d': new Set(), + }; const functionSet = new Set(); if (workpadDocs.length === 0) { @@ -106,6 +232,21 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr // make a summary of info about each workpad const workpadsInfo = workpadDocs.map((workpad) => { + let this30Days = false; + let this90Days = false; + + if (workpad['@timestamp'] !== undefined) { + const lastReadDaysAgo = moment().diff(moment(workpad['@timestamp']), 'days'); + + if (lastReadDaysAgo < 30) { + this30Days = true; + } + + if (lastReadDaysAgo < 90) { + this90Days = true; + } + } + let pages = { count: 0 }; try { pages = { count: workpad.pages.length }; @@ -121,6 +262,16 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr return page.elements.map((element) => { const ast = parseExpression(element.expression); collectFns(ast, (cFunction) => { + functionCollection.all.add(cFunction); + + if (this30Days) { + functionCollection['30d'].add(cFunction); + } + + if (this90Days) { + functionCollection['90d'].add(cFunction); + } + functionSet.add(cFunction); }); return ast.chain.length; // get the number of parts in the expression @@ -203,7 +354,9 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr elementsTotal > 0 ? { total: functionsTotal, - in_use: Array.from(functionSet), + in_use: Array.from(functionCollection.all), + in_use_30d: Array.from(functionCollection['30d']), + in_use_90d: Array.from(functionCollection['90d']), per_element: { avg: functionsTotal / functionCounts.length, min: arrayMin(functionCounts) || 0, diff --git a/x-pack/plugins/cross_cluster_replication/kibana.json b/x-pack/plugins/cross_cluster_replication/kibana.json index 292820f81adbe..f130d0173cc89 100644 --- a/x-pack/plugins/cross_cluster_replication/kibana.json +++ b/x-pack/plugins/cross_cluster_replication/kibana.json @@ -6,6 +6,7 @@ "requiredPlugins": [ "home", "licensing", + "licenseApiGuard", "management", "remoteClusters", "indexManagement", diff --git a/x-pack/plugins/cross_cluster_replication/server/plugin.ts b/x-pack/plugins/cross_cluster_replication/server/plugin.ts index 1150f191441fc..e3a1de1dbfaba 100644 --- a/x-pack/plugins/cross_cluster_replication/server/plugin.ts +++ b/x-pack/plugins/cross_cluster_replication/server/plugin.ts @@ -7,9 +7,9 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; import { CoreSetup, + CoreStart, ILegacyCustomClusterClient, Plugin, Logger, @@ -19,12 +19,11 @@ import { import { Index } from '../../index_management/server'; import { PLUGIN } from '../common/constants'; -import type { Dependencies, CcrRequestHandlerContext } from './types'; +import { SetupDependencies, StartDependencies, CcrRequestHandlerContext } from './types'; import { registerApiRoutes } from './routes'; -import { License } from './services'; import { elasticsearchJsPlugin } from './client/elasticsearch_ccr'; import { CrossClusterReplicationConfig } from './config'; -import { isEsError } from './shared_imports'; +import { License, isEsError } from './shared_imports'; import { formatEsError } from './lib/format_es_error'; async function getCustomEsClient(getStartServices: CoreSetup['getStartServices']) { @@ -77,7 +76,7 @@ export class CrossClusterReplicationServerPlugin implements Plugin { - const { state, message } = license.check(pluginId, minimumLicenseType); - const hasRequiredLicense = state === 'valid'; - - // Retrieving security checks the results of GET /_xpack as well as license state, - // so we're also checking whether the security is disabled in elasticsearch.yml. - this._isEsSecurityEnabled = license.getFeature('security').isEnabled; - - if (hasRequiredLicense) { - this.licenseStatus = { isValid: true }; - } else { - this.licenseStatus = { - isValid: false, - message: message || defaultErrorMessage, - }; - if (message) { - logger.info(message); - } - } - }); - } - - guardApiRoute(handler: RequestHandler) { - const license = this; - - return function licenseCheck( - ctx: CcrRequestHandlerContext, - request: KibanaRequest, - response: KibanaResponseFactory - ) { - const licenseStatus = license.getStatus(); - - if (!licenseStatus.isValid) { - return response.customError({ - body: { - message: licenseStatus.message || '', - }, - statusCode: 403, - }); - } - - return handler(ctx, request, response); - }; - } - - getStatus() { - return this.licenseStatus; - } - - // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility - get isEsSecurityEnabled() { - return this._isEsSecurityEnabled; - } -} diff --git a/x-pack/plugins/cross_cluster_replication/server/shared_imports.ts b/x-pack/plugins/cross_cluster_replication/server/shared_imports.ts index df9b3dd53cc1f..4252a2a5c32d4 100644 --- a/x-pack/plugins/cross_cluster_replication/server/shared_imports.ts +++ b/x-pack/plugins/cross_cluster_replication/server/shared_imports.ts @@ -6,3 +6,4 @@ */ export { isEsError } from '../../../../src/plugins/es_ui_shared/server'; +export { License } from '../../license_api_guard/server'; diff --git a/x-pack/plugins/cross_cluster_replication/server/types.ts b/x-pack/plugins/cross_cluster_replication/server/types.ts index 2bec53170084d..7314fda70027f 100644 --- a/x-pack/plugins/cross_cluster_replication/server/types.ts +++ b/x-pack/plugins/cross_cluster_replication/server/types.ts @@ -7,20 +7,23 @@ import { IRouter, ILegacyScopedClusterClient, RequestHandlerContext } from 'src/core/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import { LicensingPluginSetup } from '../../licensing/server'; +import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import { IndexManagementPluginSetup } from '../../index_management/server'; import { RemoteClustersPluginSetup } from '../../remote_clusters/server'; -import { License } from './services'; -import { isEsError } from './shared_imports'; +import { License, isEsError } from './shared_imports'; import { formatEsError } from './lib/format_es_error'; -export interface Dependencies { +export interface SetupDependencies { licensing: LicensingPluginSetup; indexManagement: IndexManagementPluginSetup; remoteClusters: RemoteClustersPluginSetup; features: FeaturesPluginSetup; } +export interface StartDependencies { + licensing: LicensingPluginStart; +} + export interface RouteDependencies { router: CcrPluginRouter; license: License; diff --git a/x-pack/plugins/cross_cluster_replication/tsconfig.json b/x-pack/plugins/cross_cluster_replication/tsconfig.json index 9c7590b9c2553..e0923553beadc 100644 --- a/x-pack/plugins/cross_cluster_replication/tsconfig.json +++ b/x-pack/plugins/cross_cluster_replication/tsconfig.json @@ -27,5 +27,6 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../license_api_guard/tsconfig.json" }, ] } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts index c42bbde22ce31..ce96b3497e9ff 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts @@ -9,7 +9,11 @@ import { APPLY_FILTER_TRIGGER } from '../../../../../../../src/plugins/data/publ import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, + IEmbeddable, + Container as EmbeddableContainer, } from '../../../../../../../src/plugins/embeddable/public'; +import { isEnhancedEmbeddable } from '../../../../../embeddable_enhanced/public'; +import { UiActionsEnhancedDrilldownTemplate as DrilldownTemplate } from '../../../../../ui_actions_enhanced/public'; /** * We know that VALUE_CLICK_TRIGGER and SELECT_RANGE_TRIGGER are also triggering APPLY_FILTER_TRIGGER. @@ -31,3 +35,47 @@ export function ensureNestedTriggers(triggers: string[]): string[] { return triggers; } + +const isEmbeddableContainer = (x: unknown): x is EmbeddableContainer => + x instanceof EmbeddableContainer; + +/** + * Given a dashboard panel embeddable, it will find the parent (dashboard + * container embeddable), then iterate through all the dashboard panels and + * generate DrilldownTemplate for each existing drilldown. + */ +export const createDrilldownTemplatesFromSiblings = ( + embeddable: IEmbeddable +): DrilldownTemplate[] => { + const templates: DrilldownTemplate[] = []; + const embeddableId = embeddable.id; + + const container = embeddable.getRoot(); + + if (!container) return templates; + if (!isEmbeddableContainer(container)) return templates; + + const childrenIds = (container as EmbeddableContainer).getChildIds(); + + for (const childId of childrenIds) { + const child = (container as EmbeddableContainer).getChild(childId); + if (child.id === embeddableId) continue; + if (!isEnhancedEmbeddable(child)) continue; + const events = child.enhancements.dynamicActions.state.get().events; + + for (const event of events) { + const template: DrilldownTemplate = { + id: event.eventId, + name: event.action.name, + icon: 'dashboardApp', + description: child.getTitle() || child.id, + config: event.action.config, + factoryId: event.action.factoryId, + triggers: event.triggers, + }; + templates.push(template); + } + } + + return templates; +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index 2f91cdc71581c..4c0db8f317e51 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -9,17 +9,17 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { Action } from '../../../../../../../../src/plugins/ui_actions/public'; import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; -import { - isEnhancedEmbeddable, - embeddableEnhancedDrilldownGrouping, -} from '../../../../../../embeddable_enhanced/public'; import { CONTEXT_MENU_TRIGGER, EmbeddableContext, } from '../../../../../../../../src/plugins/embeddable/public'; +import { + isEnhancedEmbeddable, + embeddableEnhancedDrilldownGrouping, +} from '../../../../../../embeddable_enhanced/public'; import { StartDependencies } from '../../../../plugin'; import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public'; -import { ensureNestedTriggers } from '../drilldown_shared'; +import { ensureNestedTriggers, createDrilldownTemplatesFromSiblings } from '../drilldown_shared'; export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; @@ -81,14 +81,18 @@ export class FlyoutCreateDrilldownAction implements Action { ); } + const templates = createDrilldownTemplatesFromSiblings(embeddable); + const handle = core.overlays.openFlyout( toMountPoint( - handle.close()} - viewMode={'create'} + handle.close()} /> ), { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx index e3a4ef7ac0830..44eb63bbc504b 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -24,7 +24,7 @@ import { } from '../../../../../../embeddable_enhanced/public'; import { StartDependencies } from '../../../../plugin'; import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public'; -import { ensureNestedTriggers } from '../drilldown_shared'; +import { createDrilldownTemplatesFromSiblings, ensureNestedTriggers } from '../drilldown_shared'; export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; @@ -66,14 +66,17 @@ export class FlyoutEditDrilldownAction implements Action { ); } + const templates = createDrilldownTemplatesFromSiblings(embeddable); + const handle = core.overlays.openFlyout( toMountPoint( - handle.close()} - viewMode={'manage'} + handle.close()} /> ), { diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts index 68282c1e947f7..a52fdef9819b8 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts @@ -21,13 +21,15 @@ describe('search abort controller', () => { test('immediately aborts when passed an aborted signal in the constructor', () => { const controller = new AbortController(); controller.abort(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); expect(sac.getSignal().aborted).toBe(true); }); test('aborts when input signal is aborted', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); expect(sac.getSignal().aborted).toBe(false); controller.abort(); expect(sac.getSignal().aborted).toBe(true); @@ -35,7 +37,8 @@ describe('search abort controller', () => { test('aborts when all input signals are aborted', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); const controller2 = new AbortController(); sac.addAbortSignal(controller2.signal); @@ -48,7 +51,8 @@ describe('search abort controller', () => { test('aborts explicitly even if all inputs are not aborted', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); const controller2 = new AbortController(); sac.addAbortSignal(controller2.signal); @@ -60,7 +64,8 @@ describe('search abort controller', () => { test('doesnt abort, if cleared', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); expect(sac.getSignal().aborted).toBe(false); sac.cleanup(); controller.abort(); @@ -77,7 +82,7 @@ describe('search abort controller', () => { }); test('doesnt abort on timeout, if cleared', () => { - const sac = new SearchAbortController(undefined, 100); + const sac = new SearchAbortController(100); expect(sac.getSignal().aborted).toBe(false); sac.cleanup(); timeTravel(100); @@ -85,7 +90,7 @@ describe('search abort controller', () => { }); test('aborts on timeout, even if no signals passed in', () => { - const sac = new SearchAbortController(undefined, 100); + const sac = new SearchAbortController(100); expect(sac.getSignal().aborted).toBe(false); timeTravel(100); expect(sac.getSignal().aborted).toBe(true); @@ -94,7 +99,8 @@ describe('search abort controller', () => { test('aborts on timeout, even if there are unaborted signals', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal, 100); + const sac = new SearchAbortController(100); + sac.addAbortSignal(controller.signal); expect(sac.getSignal().aborted).toBe(false); timeTravel(100); diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts index 4482a7771dc28..7bc74b56a3903 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts @@ -18,11 +18,7 @@ export class SearchAbortController { private destroyed = false; private reason?: AbortReason; - constructor(abortSignal?: AbortSignal, timeout?: number) { - if (abortSignal) { - this.addAbortSignal(abortSignal); - } - + constructor(timeout?: number) { if (timeout) { this.timeoutSub = timer(timeout).subscribe(() => { this.reason = AbortReason.Timeout; @@ -41,6 +37,7 @@ export class SearchAbortController { }; public cleanup() { + if (this.destroyed) return; this.destroyed = true; this.timeoutSub?.unsubscribe(); this.inputAbortSignals.forEach((abortSignal) => { diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 02671974e5053..0e511c545f3e2 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -23,9 +23,12 @@ import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks import { BehaviorSubject } from 'rxjs'; import * as xpackResourceNotFoundException from '../../common/search/test_data/search_phase_execution_exception.json'; -const timeTravel = (msToRun = 0) => { +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +const timeTravel = async (msToRun = 0) => { + await flushPromises(); jest.advanceTimersByTime(msToRun); - return new Promise((resolve) => setImmediate(resolve)); + return flushPromises(); }; const next = jest.fn(); @@ -39,10 +42,20 @@ let fetchMock: jest.Mock; jest.useFakeTimers(); +jest.mock('./utils', () => ({ + createRequestHash: jest.fn().mockImplementation((input) => { + return Promise.resolve(JSON.stringify(input)); + }), +})); + function mockFetchImplementation(responses: any[]) { let i = 0; - fetchMock.mockImplementation(() => { + fetchMock.mockImplementation((r) => { + if (!r.request.id) i = 0; const { time = 0, value = {}, isError = false } = responses[i++]; + value.meta = { + size: 10, + }; return new Promise((resolve, reject) => setTimeout(() => { return (isError ? reject : resolve)(value); @@ -452,7 +465,7 @@ describe('EnhancedSearchInterceptor', () => { }); }); - describe('session', () => { + describe('session tracking', () => { beforeEach(() => { const responses = [ { @@ -559,4 +572,540 @@ describe('EnhancedSearchInterceptor', () => { expect(sessionService.trackSearch).toBeCalledTimes(0); }); }); + + describe('session client caching', () => { + const sessionId = 'sessionId'; + const basicReq = { + params: { + test: 1, + }, + }; + + const basicCompleteResponse = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + const partialCompleteResponse = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + { + time: 20, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + beforeEach(() => { + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + }); + + test('should be disabled if there is no session', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should fetch different requests in a single session', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req2 = { + params: { + test: 2, + }, + }; + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should fetch the same request for two different sessions', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor + .search(basicReq, { sessionId: 'anotherSession' }) + .subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should track searches that come from cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response.subscribe({ next, error, complete }); + response2.subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).not.toBeCalled(); + await timeTravel(300); + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(4); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(2); + }); + + test('should cache partial responses', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should not cache error responses', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should deliver error to all replays', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(error).toBeCalledTimes(2); + expect(error.mock.calls[0][0].message).toEqual('Received partial response'); + expect(error.mock.calls[1][0].message).toEqual('Received partial response'); + }); + + test('should ignore anything outside params when hashing', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req = { + something: 123, + params: { + test: 1, + }, + }; + + const req2 = { + something: 321, + params: { + test: 1, + }, + }; + + searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should ignore preference when hashing', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req = { + params: { + test: 1, + preference: 123, + }, + }; + + const req2 = { + params: { + test: 1, + preference: 321, + }, + }; + + searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should return from cache for identical requests in the same session', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('aborting a search that didnt get any response should retrigger search', async () => { + mockFetchImplementation(basicCompleteResponse); + + const abortController = new AbortController(); + + // Start a search request + searchInterceptor + .search(basicReq, { sessionId, abortSignal: abortController.signal }) + .subscribe({ next, error, complete }); + + // Abort the search request before it started + abortController.abort(); + + // Time travel to make sure nothing appens + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(0); + expect(next).toBeCalledTimes(0); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + + const error2 = jest.fn(); + const next2 = jest.fn(); + const complete2 = jest.fn(); + + // Search for the same thing again + searchInterceptor + .search(basicReq, { sessionId }) + .subscribe({ next: next2, error: error2, complete: complete2 }); + + // Should search again + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(next2).toBeCalledTimes(1); + expect(error2).toBeCalledTimes(0); + expect(complete2).toBeCalledTimes(1); + }); + + test('aborting a running first search shouldnt clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + + expect(fetchMock).toBeCalledTimes(1); + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(0); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + + const next2 = jest.fn(); + const error2 = jest.fn(); + const complete2 = jest.fn(); + const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response2.subscribe({ next: next2, error: error2, complete: complete2 }); + await timeTravel(0); + + abortController.abort(); + + await timeTravel(300); + // Both searches should be tracked and untracked + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + // First search should error + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + + // Second search should complete + expect(next2).toBeCalledTimes(2); + expect(error2).toBeCalledTimes(0); + expect(complete2).toBeCalledTimes(1); + + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + }); + + test('aborting a running second search shouldnt clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + + expect(fetchMock).toBeCalledTimes(1); + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(0); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + + const next2 = jest.fn(); + const error2 = jest.fn(); + const complete2 = jest.fn(); + const response2 = searchInterceptor.search(req, { + pollInterval: 0, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next: next2, error: error2, complete: complete2 }); + await timeTravel(0); + + abortController.abort(); + + await timeTravel(300); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(2); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(1); + + expect(next2).toBeCalledTimes(1); + expect(error2).toBeCalledTimes(1); + expect(complete2).toBeCalledTimes(0); + + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + }); + + test('aborting both requests should cancel underlaying search only once', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + sessionService.trackSearch.mockImplementation(() => jest.fn()); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + + const response2 = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next, error, complete }); + await timeTravel(10); + + abortController.abort(); + + await timeTravel(300); + + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('aborting both searches should stop searching and clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + const response2 = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next, error, complete }); + await timeTravel(0); + expect(fetchMock).toBeCalledTimes(1); + + abortController.abort(); + + await timeTravel(300); + + expect(next).toBeCalledTimes(2); + expect(error).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(0); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + expect(error.mock.calls[1][0]).toBeInstanceOf(AbortError); + + // Should be called only 1 times (one partial response) + expect(fetchMock).toBeCalledTimes(1); + + // Clear mock and research + fetchMock.mockReset(); + mockFetchImplementation(partialCompleteResponse); + // Run the search again to see that we don't hit the cache + const response3 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response3.subscribe({ next, error, complete }); + + await timeTravel(10); + await timeTravel(10); + await timeTravel(300); + + // Should be called 2 times (two partial response) + expect(fetchMock).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(1); + }); + + test('aborting a completed search shouldnt effect cache', async () => { + mockFetchImplementation(basicCompleteResponse); + + const abortController = new AbortController(); + + // Start a search request + searchInterceptor + .search(basicReq, { sessionId, abortSignal: abortController.signal }) + .subscribe({ next, error, complete }); + + // Get a final response + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + // Abort the search request + abortController.abort(); + + // Search for the same thing again + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + + // Get the response from cache + expect(fetchMock).toBeCalledTimes(1); + }); + }); }); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index b9d8553d3dc5a..3e7564933a0c6 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -6,8 +6,19 @@ */ import { once } from 'lodash'; -import { throwError, Subscription } from 'rxjs'; -import { tap, finalize, catchError, filter, take, skip } from 'rxjs/operators'; +import { throwError, Subscription, from, of, fromEvent, EMPTY } from 'rxjs'; +import { + tap, + finalize, + catchError, + filter, + take, + skip, + switchMap, + shareReplay, + map, + takeUntil, +} from 'rxjs/operators'; import { TimeoutErrorMode, SearchInterceptor, @@ -16,12 +27,21 @@ import { IKibanaSearchRequest, SearchSessionState, } from '../../../../../src/plugins/data/public'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; import { ENHANCED_ES_SEARCH_STRATEGY, IAsyncSearchOptions, pollSearch } from '../../common'; +import { SearchResponseCache } from './search_response_cache'; +import { createRequestHash } from './utils'; import { SearchAbortController } from './search_abort_controller'; +const MAX_CACHE_ITEMS = 50; +const MAX_CACHE_SIZE_MB = 10; export class EnhancedSearchInterceptor extends SearchInterceptor { private uiSettingsSub: Subscription; private searchTimeout: number; + private readonly responseCache: SearchResponseCache = new SearchResponseCache( + MAX_CACHE_ITEMS, + MAX_CACHE_SIZE_MB + ); /** * @internal @@ -38,6 +58,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { } public stop() { + this.responseCache.clear(); this.uiSettingsSub.unsubscribe(); } @@ -47,19 +68,31 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { : TimeoutErrorMode.CONTACT; } - public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { - const searchOptions = { - strategy: ENHANCED_ES_SEARCH_STRATEGY, - ...options, + private createRequestHash$(request: IKibanaSearchRequest, options: IAsyncSearchOptions) { + const { sessionId, isRestore } = options; + // Preference is used to ensure all queries go to the same set of shards and it doesn't need to be hashed + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-shard-routing.html#shard-and-node-preference + const { preference, ...params } = request.params || {}; + const hashOptions = { + ...params, + sessionId, + isRestore, }; - const { sessionId, strategy, abortSignal } = searchOptions; - const search = () => this.runSearch({ id, ...request }, searchOptions); - const searchAbortController = new SearchAbortController(abortSignal, this.searchTimeout); - this.pendingCount$.next(this.pendingCount$.getValue() + 1); - const untrackSearch = this.deps.session.isCurrentSession(options.sessionId) - ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() }) - : undefined; + return from(sessionId ? createRequestHash(hashOptions) : of(undefined)); + } + + /** + * @internal + * Creates a new pollSearch that share replays its results + */ + private runSearch$( + { id, ...request }: IKibanaSearchRequest, + options: IAsyncSearchOptions, + searchAbortController: SearchAbortController + ) { + const search = () => this.runSearch({ id, ...request }, options); + const { sessionId, strategy } = options; // track if this search's session will be send to background // if yes, then we don't need to cancel this search when it is aborted @@ -91,18 +124,97 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { tap((response) => (id = response.id)), catchError((e: Error) => { cancel(); - return throwError(this.handleSearchError(e, options, searchAbortController.isTimeout())); + return throwError(e); }), finalize(() => { - this.pendingCount$.next(this.pendingCount$.getValue() - 1); searchAbortController.cleanup(); - if (untrackSearch && this.deps.session.isCurrentSession(options.sessionId)) { - // untrack if this search still belongs to current session - untrackSearch(); - } if (savedToBackgroundSub) { savedToBackgroundSub.unsubscribe(); } + }), + // This observable is cached in the responseCache. + // Using shareReplay makes sure that future subscribers will get the final response + + shareReplay(1) + ); + } + + /** + * @internal + * Creates a new search observable and a corresponding search abort controller + * If requestHash is defined, tries to return them first from cache. + */ + private getSearchResponse$( + request: IKibanaSearchRequest, + options: IAsyncSearchOptions, + requestHash?: string + ) { + const cached = requestHash ? this.responseCache.get(requestHash) : undefined; + + const searchAbortController = + cached?.searchAbortController || new SearchAbortController(this.searchTimeout); + + // Create a new abort signal if one was not passed. This fake signal will never be aborted, + // So the underlaying search will not be aborted, even if the other consumers abort. + searchAbortController.addAbortSignal(options.abortSignal ?? new AbortController().signal); + const response$ = cached?.response$ || this.runSearch$(request, options, searchAbortController); + + if (requestHash && !this.responseCache.has(requestHash)) { + this.responseCache.set(requestHash, { + response$, + searchAbortController, + }); + } + + return { + response$, + searchAbortController, + }; + } + + public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { + const searchOptions = { + strategy: ENHANCED_ES_SEARCH_STRATEGY, + ...options, + }; + const { sessionId, abortSignal } = searchOptions; + + return this.createRequestHash$(request, searchOptions).pipe( + switchMap((requestHash) => { + const { searchAbortController, response$ } = this.getSearchResponse$( + request, + searchOptions, + requestHash + ); + + this.pendingCount$.next(this.pendingCount$.getValue() + 1); + const untrackSearch = this.deps.session.isCurrentSession(sessionId) + ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() }) + : undefined; + + // Abort the replay if the abortSignal is aborted. + // The underlaying search will not abort unless searchAbortController fires. + const aborted$ = (abortSignal ? fromEvent(abortSignal, 'abort') : EMPTY).pipe( + map(() => { + throw new AbortError(); + }) + ); + + return response$.pipe( + takeUntil(aborted$), + catchError((e) => { + return throwError( + this.handleSearchError(e, searchOptions, searchAbortController.isTimeout()) + ); + }), + finalize(() => { + this.pendingCount$.next(this.pendingCount$.getValue() - 1); + if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) { + // untrack if this search still belongs to current session + untrackSearch(); + } + }) + ); }) ); } diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts b/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts new file mode 100644 index 0000000000000..e985de5e23f7d --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts @@ -0,0 +1,318 @@ +/* + * 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. + */ + +import { interval, Observable, of, throwError } from 'rxjs'; +import { shareReplay, switchMap, take } from 'rxjs/operators'; +import { IKibanaSearchResponse } from 'src/plugins/data/public'; +import { SearchAbortController } from './search_abort_controller'; +import { SearchResponseCache } from './search_response_cache'; + +describe('SearchResponseCache', () => { + let cache: SearchResponseCache; + let searchAbortController: SearchAbortController; + const r: Array> = [ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 1, + }, + }, + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 2, + }, + }, + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 3, + }, + }, + { + isPartial: false, + isRunning: false, + rawResponse: { + t: 4, + }, + }, + ]; + + function getSearchObservable$(responses: Array> = r) { + return interval(100).pipe( + take(responses.length), + switchMap((value: number, i: number) => { + if (responses[i].rawResponse.throw === true) { + return throwError('nooo'); + } else { + return of(responses[i]); + } + }), + shareReplay(1) + ); + } + + function wrapWithAbortController(response$: Observable>) { + return { + response$, + searchAbortController, + }; + } + + beforeEach(() => { + cache = new SearchResponseCache(3, 0.1); + searchAbortController = new SearchAbortController(); + }); + + describe('Cache eviction', () => { + test('clear evicts all', () => { + const finalResult = r[r.length - 1]; + cache.set('123', wrapWithAbortController(of(finalResult))); + cache.set('234', wrapWithAbortController(of(finalResult))); + + cache.clear(); + + expect(cache.get('123')).toBeUndefined(); + expect(cache.get('234')).toBeUndefined(); + }); + + test('evicts searches that threw an exception', async () => { + const res$ = getSearchObservable$(); + const err$ = getSearchObservable$([ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 'a'.repeat(1000), + }, + }, + { + isPartial: true, + isRunning: true, + rawResponse: { + throw: true, + }, + }, + ]); + cache.set('123', wrapWithAbortController(err$)); + cache.set('234', wrapWithAbortController(res$)); + + const errHandler = jest.fn(); + await err$.toPromise().catch(errHandler); + await res$.toPromise().catch(errHandler); + + expect(errHandler).toBeCalledTimes(1); + expect(cache.get('123')).toBeUndefined(); + expect(cache.get('234')).not.toBeUndefined(); + }); + + test('evicts searches that returned an error response', async () => { + const err$ = getSearchObservable$([ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 1, + }, + }, + { + isPartial: true, + isRunning: false, + rawResponse: { + t: 2, + }, + }, + ]); + cache.set('123', wrapWithAbortController(err$)); + + const errHandler = jest.fn(); + await err$.toPromise().catch(errHandler); + + expect(errHandler).toBeCalledTimes(0); + expect(cache.get('123')).toBeUndefined(); + }); + + test('evicts oldest item if has too many cached items', async () => { + const finalResult = r[r.length - 1]; + cache.set('123', wrapWithAbortController(of(finalResult))); + cache.set('234', wrapWithAbortController(of(finalResult))); + cache.set('345', wrapWithAbortController(of(finalResult))); + cache.set('456', wrapWithAbortController(of(finalResult))); + + expect(cache.get('123')).toBeUndefined(); + expect(cache.get('234')).not.toBeUndefined(); + expect(cache.get('345')).not.toBeUndefined(); + expect(cache.get('456')).not.toBeUndefined(); + }); + + test('evicts oldest item if cache gets bigger than max size', async () => { + const largeResult$ = getSearchObservable$([ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 'a'.repeat(1000), + }, + }, + { + isPartial: false, + isRunning: false, + rawResponse: { + t: 'a'.repeat(50000), + }, + }, + ]); + + cache.set('123', wrapWithAbortController(largeResult$)); + cache.set('234', wrapWithAbortController(largeResult$)); + cache.set('345', wrapWithAbortController(largeResult$)); + + await largeResult$.toPromise(); + + expect(cache.get('123')).toBeUndefined(); + expect(cache.get('234')).not.toBeUndefined(); + expect(cache.get('345')).not.toBeUndefined(); + }); + + test('evicts from cache any single item that gets bigger than max size', async () => { + const largeResult$ = getSearchObservable$([ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 'a'.repeat(500), + }, + }, + { + isPartial: false, + isRunning: false, + rawResponse: { + t: 'a'.repeat(500000), + }, + }, + ]); + + cache.set('234', wrapWithAbortController(largeResult$)); + await largeResult$.toPromise(); + expect(cache.get('234')).toBeUndefined(); + }); + + test('get updates the insertion time of an item', async () => { + const finalResult = r[r.length - 1]; + cache.set('123', wrapWithAbortController(of(finalResult))); + cache.set('234', wrapWithAbortController(of(finalResult))); + cache.set('345', wrapWithAbortController(of(finalResult))); + + cache.get('123'); + cache.get('234'); + + cache.set('456', wrapWithAbortController(of(finalResult))); + + expect(cache.get('123')).not.toBeUndefined(); + expect(cache.get('234')).not.toBeUndefined(); + expect(cache.get('345')).toBeUndefined(); + expect(cache.get('456')).not.toBeUndefined(); + }); + }); + + describe('Observable behavior', () => { + test('caches a response and re-emits it', async () => { + const s$ = getSearchObservable$(); + cache.set('123', wrapWithAbortController(s$)); + const finalRes = await cache.get('123')!.response$.toPromise(); + expect(finalRes).toStrictEqual(r[r.length - 1]); + }); + + test('cached$ should emit same as original search$', async () => { + const s$ = getSearchObservable$(); + cache.set('123', wrapWithAbortController(s$)); + + const next = jest.fn(); + const cached$ = cache.get('123'); + + cached$!.response$.subscribe({ + next, + }); + + // wait for original search to complete + await s$!.toPromise(); + + // get final response from cached$ + const finalRes = await cached$!.response$.toPromise(); + expect(finalRes).toStrictEqual(r[r.length - 1]); + expect(next).toHaveBeenCalledTimes(4); + }); + + test('cached$ should emit only current value and keep emitting if subscribed while search$ is running', async () => { + const s$ = getSearchObservable$(); + cache.set('123', wrapWithAbortController(s$)); + + const next = jest.fn(); + let cached$: Observable> | undefined; + s$.subscribe({ + next: (res) => { + if (res.rawResponse.t === 3) { + cached$ = cache.get('123')!.response$; + cached$!.subscribe({ + next, + }); + } + }, + }); + + // wait for original search to complete + await s$!.toPromise(); + + const finalRes = await cached$!.toPromise(); + + expect(finalRes).toStrictEqual(r[r.length - 1]); + expect(next).toHaveBeenCalledTimes(2); + }); + + test('cached$ should emit only last value if subscribed after search$ was complete 1', async () => { + const finalResult = r[r.length - 1]; + const s$ = wrapWithAbortController(of(finalResult)); + cache.set('123', s$); + + // wait for original search to complete + await s$!.response$.toPromise(); + + const next = jest.fn(); + const cached$ = cache.get('123'); + cached$!.response$.subscribe({ + next, + }); + + const finalRes = await cached$!.response$.toPromise(); + + expect(finalRes).toStrictEqual(r[r.length - 1]); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('cached$ should emit only last value if subscribed after search$ was complete', async () => { + const s$ = getSearchObservable$(); + cache.set('123', wrapWithAbortController(s$)); + + // wait for original search to complete + await s$!.toPromise(); + + const next = jest.fn(); + const cached$ = cache.get('123'); + cached$!.response$.subscribe({ + next, + }); + + const finalRes = await cached$!.response$.toPromise(); + + expect(finalRes).toStrictEqual(r[r.length - 1]); + expect(next).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts b/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts new file mode 100644 index 0000000000000..1467e5bf234ff --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts @@ -0,0 +1,136 @@ +/* + * 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. + */ + +import { Observable, Subscription } from 'rxjs'; +import { IKibanaSearchResponse, isErrorResponse } from '../../../../../src/plugins/data/public'; +import { SearchAbortController } from './search_abort_controller'; + +interface ResponseCacheItem { + response$: Observable>; + searchAbortController: SearchAbortController; +} + +interface ResponseCacheItemInternal { + response$: Observable>; + searchAbortController: SearchAbortController; + size: number; + subs: Subscription; +} + +export class SearchResponseCache { + private responseCache: Map; + private cacheSize = 0; + + constructor(private maxItems: number, private maxCacheSizeMB: number) { + this.responseCache = new Map(); + } + + private byteToMb(size: number) { + return size / (1024 * 1024); + } + + private deleteItem(key: string, clearSubs = true) { + const item = this.responseCache.get(key); + if (item) { + if (clearSubs) { + item.subs.unsubscribe(); + } + this.cacheSize -= item.size; + this.responseCache.delete(key); + } + } + + private setItem(key: string, item: ResponseCacheItemInternal) { + // The deletion of the key will move it to the end of the Map's entries. + this.deleteItem(key, false); + this.cacheSize += item.size; + this.responseCache.set(key, item); + } + + public clear() { + this.cacheSize = 0; + this.responseCache.forEach((item) => { + item.subs.unsubscribe(); + }); + this.responseCache.clear(); + } + + private shrink() { + while ( + this.responseCache.size > this.maxItems || + this.byteToMb(this.cacheSize) > this.maxCacheSizeMB + ) { + const [key] = [...this.responseCache.keys()]; + this.deleteItem(key); + } + } + + public has(key: string) { + return this.responseCache.has(key); + } + + /** + * + * @param key key to cache + * @param response$ + * @returns A ReplaySubject that mimics the behavior of the original observable + * @throws error if key already exists + */ + public set(key: string, item: ResponseCacheItem) { + if (this.responseCache.has(key)) { + throw new Error('duplicate key'); + } + + const { response$, searchAbortController } = item; + + const cacheItem: ResponseCacheItemInternal = { + response$, + searchAbortController, + subs: new Subscription(), + size: 0, + }; + + this.setItem(key, cacheItem); + + cacheItem.subs.add( + response$.subscribe({ + next: (r) => { + // TODO: avoid stringiying. Get the size some other way! + const newSize = new Blob([JSON.stringify(r)]).size; + if (this.byteToMb(newSize) < this.maxCacheSizeMB && !isErrorResponse(r)) { + this.setItem(key, { + ...cacheItem, + size: newSize, + }); + this.shrink(); + } else { + // Single item is too large to be cached, or an error response returned. + // Evict and ignore. + this.deleteItem(key); + } + }, + error: (e) => { + // Evict item on error + this.deleteItem(key); + }, + }) + ); + this.shrink(); + } + + public get(key: string): ResponseCacheItem | undefined { + const item = this.responseCache.get(key); + if (item) { + // touch the item, and move it to the end of the map's entries + this.setItem(key, item); + return { + response$: item.response$, + searchAbortController: item.searchAbortController, + }; + } + } +} diff --git a/x-pack/plugins/data_enhanced/public/search/utils.ts b/x-pack/plugins/data_enhanced/public/search/utils.ts new file mode 100644 index 0000000000000..c6c648dbb5488 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import stringify from 'json-stable-stringify'; + +export async function createRequestHash(keys: Record) { + const msgBuffer = new TextEncoder().encode(stringify(keys)); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => ('00' + b.toString(16)).slice(-2)).join(''); +} diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 88a89af6be3d0..9b699d6ce007c 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -162,9 +162,9 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.delete(type, id, options); } - public async find(options: SavedObjectsFindOptions) { + public async find(options: SavedObjectsFindOptions) { return await this.handleEncryptedAttributesInBulkResponse( - await this.options.baseClient.find(options), + await this.options.baseClient.find(options), undefined ); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index 2d7e3438d4c02..dfca497807718 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -211,8 +211,7 @@ export const EngineNav: React.FC = () => { )} {canManageEngineSynonyms && ( {SYNONYMS_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index 27ef42e72764c..d01958942e0a1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -22,6 +22,7 @@ import { CurationsRouter } from '../curations'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; +import { Synonyms } from '../synonyms'; import { EngineRouter } from './engine_router'; @@ -100,6 +101,13 @@ describe('EngineRouter', () => { expect(wrapper.find(AnalyticsRouter)).toHaveLength(1); }); + it('renders a synonyms view', () => { + setMockValues({ ...values, myRole: { canManageEngineSynonyms: true } }); + const wrapper = shallow(); + + expect(wrapper.find(Synonyms)).toHaveLength(1); + }); + it('renders a curations view', () => { setMockValues({ ...values, myRole: { canManageEngineCurations: true } }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 8c6f248e9ce8e..c246af3611563 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -27,7 +27,7 @@ import { // ENGINE_CRAWLER_PATH, // META_ENGINE_SOURCE_ENGINES_PATH, ENGINE_RELEVANCE_TUNING_PATH, - // ENGINE_SYNONYMS_PATH, + ENGINE_SYNONYMS_PATH, ENGINE_CURATIONS_PATH, ENGINE_RESULT_SETTINGS_PATH, // ENGINE_SEARCH_UI_PATH, @@ -39,8 +39,8 @@ import { CurationsRouter } from '../curations'; import { DocumentDetail, Documents } from '../documents'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; - import { ResultSettings } from '../result_settings'; +import { Synonyms } from '../synonyms'; import { EngineLogic, getEngineBreadcrumbs } from './'; @@ -53,7 +53,7 @@ export const EngineRouter: React.FC = () => { // canViewEngineCrawler, // canViewMetaEngineSourceEngines, canManageEngineRelevanceTuning, - // canManageEngineSynonyms, + canManageEngineSynonyms, canManageEngineCurations, canManageEngineResultSettings, // canManageEngineSearchUi, @@ -107,6 +107,11 @@ export const EngineRouter: React.FC = () => { )} + {canManageEngineSynonyms && ( + + + + )} {canManageEngineResultSettings && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx index e5a901f8d0779..70bc49421a4f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx @@ -15,6 +15,8 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { EuiPageHeader, EuiEmptyPrompt } from '@elastic/eui'; +import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; + import { ResultSettings } from './result_settings'; import { ResultSettingsTable } from './result_settings_table'; import { SampleResponse } from './sample_response'; @@ -110,6 +112,14 @@ describe('ResultSettings', () => { expect(actions.clearAllFields).toHaveBeenCalled(); }); + it('will prevent user from leaving the page if there are unsaved changes', () => { + setMockValues({ + ...values, + stagedUpdates: true, + }); + expect(subject().find(UnsavedChangesPrompt).prop('hasUnsavedChanges')).toBe(true); + }); + describe('when there is no schema yet', () => { let wrapper: ShallowWrapper; beforeAll(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index 285d8fef35770..bea5bcc548fab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -25,6 +25,7 @@ import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; +import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; import { getEngineBreadcrumbs } from '../engine'; @@ -39,6 +40,11 @@ const CLEAR_BUTTON_LABEL = i18n.translate( { defaultMessage: 'Clear all values' } ); +const UNSAVED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.unsavedChangesMessage', + { defaultMessage: 'Result Settings have not been saved. Are you sure you want to leave?' } +); + export const ResultSettings: React.FC = () => { const { dataLoading, schema, stagedUpdates, resultFieldsAtDefaultSettings } = useValues( ResultSettingsLogic @@ -60,6 +66,7 @@ export const ResultSettings: React.FC = () => { return ( <> + { 'An error occured.' ); }); - - it('does nothing if an empty object is passed for the resultFields parameter', async () => { - mount(); - jest.spyOn(SampleResponseLogic.actions, 'getSearchResultsSuccess'); - - SampleResponseLogic.actions.getSearchResults('foo', {}); - - jest.runAllTimers(); - await nextTick(); - - expect(SampleResponseLogic.actions.getSearchResultsSuccess).not.toHaveBeenCalled(); - }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.ts index 808a7ec9c65dc..c64cb3465b311 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.ts @@ -63,7 +63,6 @@ export const SampleResponseLogic = kea ({ getSearchResults: async ({ query, resultFields }, breakpoint) => { - if (Object.keys(resultFields).length < 1) return; await breakpoint(250); const { http } = HttpLogic.values; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts index 5b0fde246ed44..177bc5eade0f6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts @@ -6,3 +6,4 @@ */ export { SYNONYMS_TITLE } from './constants'; +export { Synonyms } from './synonyms'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx new file mode 100644 index 0000000000000..e093442f77b77 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { Synonyms } from './'; + +describe('Synonyms', () => { + it('renders', () => { + shallow(); + // TODO: Check for Synonym cards, Synonym modal + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx new file mode 100644 index 0000000000000..0b18271660911 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { getEngineBreadcrumbs } from '../engine'; + +import { SYNONYMS_TITLE } from './constants'; + +export const Synonyms: React.FC = () => { + return ( + <> + + + + TODO + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/account_header/account_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/account_header/account_header.tsx index a878d87af09e4..87ee108f21c73 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/account_header/account_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/account_header/account_header.tsx @@ -97,7 +97,7 @@ export const AccountHeader: React.FC = () => { > - + {ACCOUNT_NAV.SEARCH} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.test.tsx index f5c9858714cfd..7e06e0c4aa2f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.test.tsx @@ -39,7 +39,7 @@ describe('SourceRow', () => { const source = { ...contentSources[0], status: 'error', - errorReason: 'credentials_invalid', + errorReason: 'OAuth access token could not be refreshed', }; const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx index b6dcaa271d8d8..433e90d75ed64 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx @@ -34,7 +34,9 @@ import { SourceIcon } from '../source_icon'; import './source_row.scss'; -const CREDENTIALS_INVALID_ERROR_REASON = 'credentials_invalid'; +// i18n is not needed here because this is only used to check against the server error, which +// is not translated by the Kibana team at this time. +const CREDENTIALS_REFRESH_NEEDED_PREFIX = 'OAuth access token could not be refreshed'; export interface ISourceRow { showDetails?: boolean; @@ -67,7 +69,10 @@ export const SourceRow: React.FC = ({ const isIndexing = status === statuses.INDEXING; const hasError = status === statuses.ERROR || status === statuses.DISCONNECTED; const showFix = - isOrganization && hasError && allowsReauth && errorReason === CREDENTIALS_INVALID_ERROR_REASON; + isOrganization && + hasError && + allowsReauth && + errorReason?.startsWith(CREDENTIALS_REFRESH_NEEDED_PREFIX); const rowClass = classNames({ 'source-row--error': hasError }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index e08050335671e..59e43b103db40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -19,9 +19,10 @@ export const LEAVE_FEEDBACK_EMAIL = 'support@elastic.co'; export const LEAVE_FEEDBACK_URL = `mailto:${LEAVE_FEEDBACK_EMAIL}?Subject=Elastic%20Workplace%20Search%20Feedback`; export const DOCS_PREFIX = docLinks.workplaceSearchBase; +export const PERMISSIONS_DOCS_URL = `${DOCS_PREFIX}/workplace-search-permissions.html`; export const DOCUMENT_PERMISSIONS_DOCS_URL = `${DOCS_PREFIX}/workplace-search-sources-document-permissions.html`; export const DOCUMENT_PERMISSIONS_SYNC_DOCS_URL = `${DOCUMENT_PERMISSIONS_DOCS_URL}#sources-permissions-synchronizing`; -export const PRIVATE_SOURCES_DOCS_URL = `${DOCUMENT_PERMISSIONS_DOCS_URL}#sources-permissions-org-private`; +export const PRIVATE_SOURCES_DOCS_URL = `${PERMISSIONS_DOCS_URL}#organizational-sources-private-sources`; export const EXTERNAL_IDENTITIES_DOCS_URL = `${DOCS_PREFIX}/workplace-search-external-identities-api.html`; export const SECURITY_DOCS_URL = `${DOCS_PREFIX}/workplace-search-security.html`; export const SMTP_DOCS_URL = `${DOCS_PREFIX}/workplace-search-smtp-mailer.html`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx index 8819367cacd1f..80d35553bb8bb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx @@ -126,7 +126,7 @@ export const AddSourceList: React.FC = () => { - + = ({ tabId }) => { onTabClick={onSelectedTabChanged} /> ) : ( - + {DISPLAY_SETTINGS_EMPTY_TITLE}} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index a5a2d8ab73d94..86c911e7e0b00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -116,7 +116,12 @@ export const Overview: React.FC = () => { const emptyState = ( <> - + {SOURCES_NO_CONTENT_TITLE}} iconType="documents" @@ -163,7 +168,12 @@ export const Overview: React.FC = () => { const emptyState = ( <> - + {EMPTY_ACTIVITY_TITLE}} iconType="clock" diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx index f31f7049ebf36..72a3804c4443b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx @@ -140,7 +140,7 @@ export const Schema: React.FC = () => { ) : ( - + {SCHEMA_EMPTY_SCHEMA_TITLE}} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx index 12399d4822a13..8aa644827709a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx @@ -34,7 +34,6 @@ import { SourceContent } from './source_content'; describe('SourceContent', () => { const setActivePage = jest.fn(); const searchContentSourceDocuments = jest.fn(); - const resetSourceState = jest.fn(); const setContentFilterValue = jest.fn(); const mockValues = { @@ -51,7 +50,6 @@ describe('SourceContent', () => { setMockActions({ setActivePage, searchContentSourceDocuments, - resetSourceState, setContentFilterValue, }); setMockValues({ ...mockValues }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx index cc086f9c829d9..fbafe54df7493 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx @@ -56,12 +56,9 @@ const MAX_LENGTH = 28; export const SourceContent: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); - const { - setActivePage, - searchContentSourceDocuments, - resetSourceState, - setContentFilterValue, - } = useActions(SourceLogic); + const { setActivePage, searchContentSourceDocuments, setContentFilterValue } = useActions( + SourceLogic + ); const { contentSource: { id, serviceType, urlField, titleField, urlFieldIsLinkable, isFederatedSource }, @@ -74,10 +71,6 @@ export const SourceContent: React.FC = () => { sectionLoading, } = useValues(SourceLogic); - useEffect(() => { - return resetSourceState; - }, []); - useEffect(() => { searchContentSourceDocuments(id); }, [contentFilterValue, activePage]); @@ -106,7 +99,7 @@ export const SourceContent: React.FC = () => { const isCustomSource = serviceType === CUSTOM_SERVICE_TYPE; const emptyState = ( - + {emptyMessage}} iconType="documents" diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx index c6cefba317cce..b2a4488b04107 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx @@ -23,7 +23,6 @@ import { SourceSettings } from './source_settings'; describe('SourceSettings', () => { const updateContentSource = jest.fn(); const removeContentSource = jest.fn(); - const resetSourceState = jest.fn(); const getSourceConfigData = jest.fn(); const contentSource = fullContentSources[0]; const buttonLoading = false; @@ -41,7 +40,6 @@ describe('SourceSettings', () => { setMockActions({ updateContentSource, removeContentSource, - resetSourceState, getSourceConfigData, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 7ba53822534cf..4bc623ac9fdf8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -52,7 +52,7 @@ import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; export const SourceSettings: React.FC = () => { - const { updateContentSource, removeContentSource, resetSourceState } = useActions(SourceLogic); + const { updateContentSource, removeContentSource } = useActions(SourceLogic); const { getSourceConfigData } = useActions(AddSourceLogic); const { @@ -68,7 +68,6 @@ export const SourceSettings: React.FC = () => { useEffect(() => { getSourceConfigData(serviceType); - return resetSourceState; }, []); const { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx index 114df3cf41e39..128c65eeb95da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx @@ -81,7 +81,7 @@ export const PrivateSources: React.FC = () => { ); const privateSourcesEmptyState = ( - + {PRIVATE_EMPTY_TITLE}} /> @@ -107,7 +107,7 @@ export const PrivateSources: React.FC = () => { ); const sharedSourcesEmptyState = ( - + >({ ...contentSource, summary, }), + resetSourceState: () => ({} as ContentSourceFullData), }, ], dataLoading: [ true, { onInitializeSource: () => false, - resetSourceState: () => false, + resetSourceState: () => true, }, ], buttonLoading: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx index b844c86abb919..b14ea4ebd7a73 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -47,12 +47,13 @@ import { SourceLogic } from './source_logic'; export const SourceRouter: React.FC = () => { const { sourceId } = useParams() as { sourceId: string }; - const { initializeSource } = useActions(SourceLogic); + const { initializeSource, resetSourceState } = useActions(SourceLogic); const { contentSource, dataLoading } = useValues(SourceLogic); const { isOrganization } = useValues(AppLogic); useEffect(() => { initializeSource(sourceId); + return resetSourceState; }, []); if (dataLoading) return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts index a44144666d139..b4355ba7aa586 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/constants.ts @@ -15,6 +15,27 @@ export const DELETE_ROLE_MAPPING_MESSAGE = i18n.translate( } ); +export const ROLE_MAPPING_DELETED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.roleMappingDeletedMessage', + { + defaultMessage: 'Successfully deleted role mapping', + } +); + +export const ROLE_MAPPING_CREATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.roleMappingCreatedMessage', + { + defaultMessage: 'Role mapping successfully created.', + } +); + +export const ROLE_MAPPING_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.roleMappingUpdatedMessage', + { + defaultMessage: 'Role mapping successfully updated.', + } +); + export const DEFAULT_GROUP_NAME = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.roleMapping.defaultGroupName', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts index b43bda3bb228e..6e3b74f95f707 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts @@ -7,7 +7,11 @@ import { kea, MakeLogicType } from 'kea'; -import { clearFlashMessages, flashAPIErrors } from '../../../shared/flash_messages'; +import { + clearFlashMessages, + flashAPIErrors, + setSuccessMessage, +} from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { KibanaLogic } from '../../../shared/kibana'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; @@ -15,7 +19,13 @@ import { AttributeName } from '../../../shared/types'; import { ROLE_MAPPINGS_PATH } from '../../routes'; import { RoleGroup, WSRoleMapping, Role } from '../../types'; -import { DELETE_ROLE_MAPPING_MESSAGE, DEFAULT_GROUP_NAME } from './constants'; +import { + DELETE_ROLE_MAPPING_MESSAGE, + ROLE_MAPPING_DELETED_MESSAGE, + ROLE_MAPPING_CREATED_MESSAGE, + ROLE_MAPPING_UPDATED_MESSAGE, + DEFAULT_GROUP_NAME, +} from './constants'; interface RoleMappingsServerDetails { multipleAuthProvidersConfig: boolean; @@ -265,6 +275,7 @@ export const RoleMappingsLogic = kea { + describe('GET /api/app_search/engines/{engineName}/synonyms', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/synonyms', + }); + + registerSynonymsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/synonyms/collection', + }); + }); + + describe('validates', () => { + it('with pagination query params', () => { + const request = { + query: { + 'page[current]': 1, + 'page[size]': 10, + }, + }; + mockRouter.shouldValidate(request); + }); + + it('missing query params', () => { + const request = { query: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); + + describe('POST /api/app_search/engines/{engineName}/synonyms', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/synonyms', + }); + + registerSynonymsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/synonyms/collection', + }); + }); + + describe('validates', () => { + it('with synonyms', () => { + const request = { + body: { + synonyms: ['a', 'b', 'c'], + }, + }; + mockRouter.shouldValidate(request); + }); + + it('empty synonyms array', () => { + const request = { + body: { + queries: [], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('only one synonym', () => { + const request = { + body: { + queries: ['a'], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('empty synonym strings', () => { + const request = { + body: { + queries: ['', '', ''], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('missing synonyms', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); + + describe('PUT /api/app_search/engines/{engineName}/synonyms/{synonymId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/app_search/engines/{engineName}/synonyms/{synonymId}', + }); + + registerSynonymsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/synonyms/:synonymId', + }); + }); + + describe('validates', () => { + it('with synonyms', () => { + const request = { + body: { + synonyms: ['a', 'b', 'c'], + }, + }; + mockRouter.shouldValidate(request); + }); + + it('empty synonyms array', () => { + const request = { + body: { + queries: [], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('only one synonym', () => { + const request = { + body: { + queries: ['a'], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('empty synonym strings', () => { + const request = { + body: { + queries: ['', '', ''], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('missing synonyms', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); + + describe('DELETE /api/app_search/engines/{engineName}/synonyms/{synonymId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'delete', + path: '/api/app_search/engines/{engineName}/synonyms/{synonymId}', + }); + + registerSynonymsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/synonyms/:synonymId', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/synonyms.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/synonyms.ts new file mode 100644 index 0000000000000..1be58f00c476a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/synonyms.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +const synonymsSchema = schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 2 }); + +export function registerSynonymsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/synonyms', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object({ + 'page[current]': schema.number(), + 'page[size]': schema.number(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/synonyms/collection', + }) + ); + + router.post( + { + path: '/api/app_search/engines/{engineName}/synonyms', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + synonyms: synonymsSchema, + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/synonyms/collection', + }) + ); + + router.put( + { + path: '/api/app_search/engines/{engineName}/synonyms/{synonymId}', + validate: { + params: schema.object({ + engineName: schema.string(), + synonymId: schema.string(), + }), + body: schema.object({ + synonyms: synonymsSchema, + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/synonyms/:synonymId', + }) + ); + + router.delete( + { + path: '/api/app_search/engines/{engineName}/synonyms/{synonymId}', + validate: { + params: schema.object({ + engineName: schema.string(), + synonymId: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/synonyms/:synonymId', + }) + ); +} diff --git a/x-pack/plugins/file_data_visualizer/common/constants.ts b/x-pack/plugins/file_data_visualizer/common/constants.ts new file mode 100644 index 0000000000000..819549a7eb4e6 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/common/constants.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const UI_SETTING_MAX_FILE_SIZE = 'fileUpload:maxFileSize'; + +export const MB = Math.pow(2, 20); +export const MAX_FILE_SIZE = '100MB'; +export const MAX_FILE_SIZE_BYTES = 104857600; // 100MB + +export const ABSOLUTE_MAX_FILE_SIZE_BYTES = 1073741274; // 1GB +export const FILE_SIZE_DISPLAY_FORMAT = '0,0.[0] b'; + +// Value to use in the Elasticsearch index mapping meta data to identify the +// index as having been created by the File Data Visualizer. +export const INDEX_META_DATA_CREATED_BY = 'file-data-visualizer'; + +export const JOB_FIELD_TYPES = { + BOOLEAN: 'boolean', + DATE: 'date', + GEO_POINT: 'geo_point', + GEO_SHAPE: 'geo_shape', + IP: 'ip', + KEYWORD: 'keyword', + NUMBER: 'number', + TEXT: 'text', + UNKNOWN: 'unknown', +} as const; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/file_data_visualizer/common/index.ts similarity index 82% rename from x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/index.ts rename to x-pack/plugins/file_data_visualizer/common/index.ts index a9d957c985287..f4d74984a7d78 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/index.ts +++ b/x-pack/plugins/file_data_visualizer/common/index.ts @@ -5,4 +5,5 @@ * 2.0. */ -export * from './connected_flyout_manage_drilldowns'; +export * from './constants'; +export * from './types'; diff --git a/x-pack/plugins/file_data_visualizer/common/types.ts b/x-pack/plugins/file_data_visualizer/common/types.ts new file mode 100644 index 0000000000000..edfe8b3575c8d --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/common/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { JOB_FIELD_TYPES } from './constants'; + +export type InputData = any[]; + +export type JobFieldType = typeof JOB_FIELD_TYPES[keyof typeof JOB_FIELD_TYPES]; + +export interface DataVisualizerTableState { + pageSize: number; + pageIndex: number; + sortField: string; + sortDirection: string; + visibleFieldTypes: string[]; + visibleFieldNames: string[]; + showDistributions: boolean; +} diff --git a/x-pack/plugins/file_data_visualizer/jest.config.js b/x-pack/plugins/file_data_visualizer/jest.config.js new file mode 100644 index 0000000000000..90d4cfb81f11f --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/jest.config.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/file_data_visualizer'], +}; diff --git a/x-pack/plugins/file_data_visualizer/kibana.json b/x-pack/plugins/file_data_visualizer/kibana.json new file mode 100644 index 0000000000000..721352cff7c95 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/kibana.json @@ -0,0 +1,27 @@ +{ + "id": "fileDataVisualizer", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": [ + "data", + "usageCollection", + "embeddable", + "share", + "discover", + "fileUpload" + ], + "optionalPlugins": [ + "security", + "maps" + ], + "requiredBundles": [ + "kibanaReact", + "maps", + "esUiShared" + ], + "extraPublicDirs": [ + "common" + ] +} diff --git a/x-pack/plugins/file_data_visualizer/public/api/index.ts b/x-pack/plugins/file_data_visualizer/public/api/index.ts new file mode 100644 index 0000000000000..13efd80133349 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/api/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazyLoadModules } from '../lazy_load_bundle'; +import { FileDataVisualizer } from '../application'; + +export async function getFileDataVisualizerComponent(): Promise { + const modules = await lazyLoadModules(); + return modules.FileDataVisualizer; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/_index.scss rename to x-pack/plugins/file_data_visualizer/public/application/_index.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/_index.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/_index.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/_about_panel.scss b/x-pack/plugins/file_data_visualizer/public/application/components/about_panel/_about_panel.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/_about_panel.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/about_panel/_about_panel.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/about_panel/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/_index.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/about_panel/_index.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/about_panel.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/about_panel/about_panel.tsx similarity index 93% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/about_panel.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/about_panel/about_panel.tsx index c768a422cfa5a..e4f59c492fa1c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/about_panel.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/about_panel/about_panel.tsx @@ -43,7 +43,7 @@ export const AboutPanel: FC = ({ onFilePickerChange }) => { {

diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/about_panel/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/about_panel/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/welcome_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/about_panel/welcome_content.tsx similarity index 78% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/welcome_content.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/about_panel/welcome_content.tsx index 2c441e42dea2f..684b6dadcb290 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/welcome_content.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/about_panel/welcome_content.tsx @@ -21,26 +21,22 @@ import { import { ExperimentalBadge } from '../experimental_badge'; -import { useMlKibana } from '../../../../contexts/kibana'; +import { useFileDataVisualizerKibana } from '../../kibana_context'; export const WelcomeContent: FC = () => { const toolTipContent = i18n.translate( - 'xpack.ml.fileDatavisualizer.welcomeContent.experimentalFeatureTooltip', + 'xpack.fileDataVisualizer.welcomeContent.experimentalFeatureTooltip', { defaultMessage: "Experimental feature. We'd love to hear your feedback.", } ); const { - services: { fileUpload }, - } = useMlKibana(); - - if (fileUpload === undefined) { - // eslint-disable-next-line no-console - console.error('File upload plugin not available'); - return null; - } - const maxFileSize = fileUpload.getMaxBytesFormatted(); + services: { + fileUpload: { getMaxBytesFormatted }, + }, + } = useFileDataVisualizerKibana(); + const maxFileSize = getMaxBytesFormatted(); return ( @@ -51,7 +47,7 @@ export const WelcomeContent: FC = () => {

, @@ -63,7 +59,7 @@ export const WelcomeContent: FC = () => {

@@ -73,7 +69,7 @@ export const WelcomeContent: FC = () => {

@@ -87,7 +83,7 @@ export const WelcomeContent: FC = () => {

@@ -103,7 +99,7 @@ export const WelcomeContent: FC = () => {

@@ -119,7 +115,7 @@ export const WelcomeContent: FC = () => {

@@ -130,7 +126,7 @@ export const WelcomeContent: FC = () => {

@@ -140,7 +136,7 @@ export const WelcomeContent: FC = () => {

= ({ results }) => { const items = createDisplayItems(results); @@ -19,7 +19,7 @@ export const AnalysisSummary: FC<{ results: FindFileStructureResponse }> = ({ re

@@ -37,7 +37,7 @@ function createDisplayItems(results: FindFileStructureResponse) { { title: ( ), @@ -53,7 +53,7 @@ function createDisplayItems(results: FindFileStructureResponse) { items.push({ title: ( ), @@ -64,7 +64,7 @@ function createDisplayItems(results: FindFileStructureResponse) { items.push({ title: ( ), @@ -74,7 +74,7 @@ function createDisplayItems(results: FindFileStructureResponse) { items.push({ title: ( ), @@ -87,7 +87,7 @@ function createDisplayItems(results: FindFileStructureResponse) { items.push({ title: ( ), @@ -99,7 +99,7 @@ function createDisplayItems(results: FindFileStructureResponse) { items.push({ title: ( ), @@ -111,7 +111,7 @@ function createDisplayItems(results: FindFileStructureResponse) { items.push({ title: ( = ({ mode, onChangeMode, onCancel, di content={ disableImport ? ( ) : null @@ -52,7 +52,7 @@ export const BottomBar: FC = ({ mode, onChangeMode, onCancel, di data-test-subj="mlFileDataVisOpenImportPageButton" > @@ -61,7 +61,7 @@ export const BottomBar: FC = ({ mode, onChangeMode, onCancel, di onCancel()}> @@ -76,7 +76,7 @@ export const BottomBar: FC = ({ mode, onChangeMode, onCancel, di onChangeMode(DATAVISUALIZER_MODE.READ)}> @@ -84,7 +84,7 @@ export const BottomBar: FC = ({ mode, onChangeMode, onCancel, di onCancel()}> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/bottom_bar/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/bottom_bar/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_field_label.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/combined_field_label.tsx similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_field_label.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/combined_field_label.tsx diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_form.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/combined_fields_form.tsx similarity index 87% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_form.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/combined_fields_form.tsx index 02ead5c26f959..fddab3edc3ec0 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_form.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/combined_fields_form.tsx @@ -29,13 +29,13 @@ import { removeCombinedFieldsFromMappings, removeCombinedFieldsFromPipeline, } from './utils'; -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; +import { FindFileStructureResponse } from '../../../../../file_upload/common'; interface Props { mappingsString: string; pipelineString: string; - onMappingsStringChange(): void; - onPipelineStringChange(): void; + onMappingsStringChange(mappings: string): void; + onPipelineStringChange(pipeline: string): void; combinedFields: CombinedField[]; onCombinedFieldsChange(combinedFields: CombinedField[]): void; results: FindFileStructureResponse; @@ -72,11 +72,9 @@ export class CombinedFieldsForm extends Component { const pipeline = this.parsePipeline(); this.props.onMappingsStringChange( - // @ts-expect-error JSON.stringify(addCombinedFieldsToMappings(mappings, [combinedField]), null, 2) ); this.props.onPipelineStringChange( - // @ts-expect-error JSON.stringify(addCombinedFieldsToPipeline(pipeline, [combinedField]), null, 2) ); this.props.onCombinedFieldsChange([...this.props.combinedFields, combinedField]); @@ -99,11 +97,9 @@ export class CombinedFieldsForm extends Component { const removedCombinedFields = updatedCombinedFields.splice(index, 1); this.props.onMappingsStringChange( - // @ts-expect-error JSON.stringify(removeCombinedFieldsFromMappings(mappings, removedCombinedFields), null, 2) ); this.props.onPipelineStringChange( - // @ts-expect-error JSON.stringify(removeCombinedFieldsFromPipeline(pipeline, removedCombinedFields), null, 2) ); this.props.onCombinedFieldsChange(updatedCombinedFields); @@ -114,7 +110,7 @@ export class CombinedFieldsForm extends Component { return JSON.parse(this.props.mappingsString); } catch (error) { throw new Error( - i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsForm.mappingsParseError', { + i18n.translate('xpack.fileDataVisualizer.combinedFieldsForm.mappingsParseError', { defaultMessage: 'Error parsing mappings: {error}', values: { error: error.message }, }) @@ -127,7 +123,7 @@ export class CombinedFieldsForm extends Component { return JSON.parse(this.props.pipelineString); } catch (error) { throw new Error( - i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsForm.pipelineParseError', { + i18n.translate('xpack.fileDataVisualizer.combinedFieldsForm.pipelineParseError', { defaultMessage: 'Error parsing pipeline: {error}', values: { error: error.message }, }) @@ -153,7 +149,7 @@ export class CombinedFieldsForm extends Component { }; render() { - const geoPointLabel = i18n.translate('xpack.ml.fileDatavisualizer.geoPointCombinedFieldLabel', { + const geoPointLabel = i18n.translate('xpack.fileDataVisualizer.geoPointCombinedFieldLabel', { defaultMessage: 'Add geo point field', }); const panels = [ @@ -180,7 +176,7 @@ export class CombinedFieldsForm extends Component { ]; return ( @@ -196,11 +192,11 @@ export class CombinedFieldsForm extends Component { iconType="trash" color="danger" onClick={this.removeCombinedField.bind(null, idx)} - title={i18n.translate('xpack.ml.fileDatavisualizer.removeCombinedFieldsLabel', { + title={i18n.translate('xpack.fileDataVisualizer.removeCombinedFieldsLabel', { defaultMessage: 'Remove combined field', })} aria-label={i18n.translate( - 'xpack.ml.fileDatavisualizer.removeCombinedFieldsLabel', + 'xpack.fileDataVisualizer.removeCombinedFieldsLabel', { defaultMessage: 'Remove combined field', } @@ -220,7 +216,7 @@ export class CombinedFieldsForm extends Component { isDisabled={this.props.isDisabled} > diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_read_only_form.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/combined_fields_read_only_form.tsx similarity index 83% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_read_only_form.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/combined_fields_read_only_form.tsx index dc8e839b7defe..978383f8e5e10 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_read_only_form.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/combined_fields_read_only_form.tsx @@ -20,10 +20,10 @@ export function CombinedFieldsReadOnlyForm({ }) { return combinedFields.length ? ( diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/geo_point.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/geo_point.tsx similarity index 90% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/geo_point.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/geo_point.tsx index 5ae2e5de681c3..578d22384be33 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/geo_point.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/geo_point.tsx @@ -29,7 +29,7 @@ import { getFieldNames, getNameCollisionMsg, } from './utils'; -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; +import { FindFileStructureResponse } from '../../../../../file_upload/common'; interface Props { addCombinedField: (combinedField: CombinedField) => void; @@ -119,7 +119,7 @@ export class GeoPointForm extends Component { return ( @@ -131,7 +131,7 @@ export class GeoPointForm extends Component { @@ -143,7 +143,7 @@ export class GeoPointForm extends Component { { onChange={this.onGeoPointFieldChange} isInvalid={this.state.geoPointFieldError !== ''} aria-label={i18n.translate( - 'xpack.ml.fileDatavisualizer.geoPointForm.geoPointFieldAriaLabel', + 'xpack.fileDataVisualizer.geoPointForm.geoPointFieldAriaLabel', { defaultMessage: 'Geo point field, required field', } @@ -179,7 +179,7 @@ export class GeoPointForm extends Component { onClick={this.onSubmit} > diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/types.ts b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/types.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/types.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/types.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.test.ts b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/utils.test.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.test.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/utils.test.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.ts b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/utils.ts similarity index 97% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/utils.ts index ab08398fcda02..efd166d4821c5 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/combined_fields/utils.ts @@ -13,7 +13,7 @@ import { FindFileStructureResponse, IngestPipeline, Mappings, -} from '../../../../../../../file_upload/common'; +} from '../../../../../file_upload/common'; const COMMON_LAT_NAMES = ['latitude', 'lat']; const COMMON_LON_NAMES = ['longitude', 'long', 'lon']; @@ -127,7 +127,7 @@ export function createGeoPointCombinedField( } export function getNameCollisionMsg(name: string) { - return i18n.translate('xpack.ml.fileDatavisualizer.nameCollisionMsg', { + return i18n.translate('xpack.fileDataVisualizer.nameCollisionMsg', { defaultMessage: '"{name}" already exists, please provide a unique name', values: { name }, }); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/__snapshots__/overrides.test.js.snap b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/__snapshots__/overrides.test.js.snap similarity index 96% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/__snapshots__/overrides.test.js.snap rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/__snapshots__/overrides.test.js.snap index 6ab89fe3e4b2d..00dd652457daf 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/__snapshots__/overrides.test.js.snap +++ b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/__snapshots__/overrides.test.js.snap @@ -13,7 +13,7 @@ exports[`Overrides render overrides 1`] = ` label={ } @@ -33,7 +33,7 @@ exports[`Overrides render overrides 1`] = ` label={ } @@ -94,7 +94,7 @@ exports[`Overrides render overrides 1`] = ` label={ } @@ -335,7 +335,7 @@ exports[`Overrides render overrides 1`] = ` label={ } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/_edit_flyout.scss b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/_edit_flyout.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/_edit_flyout.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/_edit_flyout.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/_index.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/_index.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/edit_flyout.js b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/edit_flyout.js similarity index 91% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/edit_flyout.js rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/edit_flyout.js index c26e504087b46..7cdee6f823bd6 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/edit_flyout.js +++ b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/edit_flyout.js @@ -69,7 +69,7 @@ export class EditFlyout extends Component {

@@ -96,7 +96,7 @@ export class EditFlyout extends Component { @@ -108,7 +108,7 @@ export class EditFlyout extends Component { fill > diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/index.js b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/index.js rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/index.js diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/options/index.js b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/options/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/options/index.js rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/options/index.js diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/options/option_lists.js b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/options/option_lists.js similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/options/option_lists.js rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/options/option_lists.js diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/options/options.js b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/options/options.js similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/options/options.js rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/options/options.js diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides.js b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides.js similarity index 90% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides.js rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides.js index 23c7b869f5e6f..cb0839b335a97 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides.js +++ b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides.js @@ -31,7 +31,7 @@ import { // getCharsetOptions, } from './options'; import { isTimestampFormatValid } from './overrides_validation'; -import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { TIMESTAMP_OPTIONS, CUSTOM_DROPDOWN_OPTION } from './options/option_lists'; @@ -52,7 +52,7 @@ class OverridesUI extends Component { } linesToSampleErrors = i18n.translate( - 'xpack.ml.fileDatavisualizer.editFlyout.overrides.linesToSampleErrorMessage', + 'xpack.fileDataVisualizer.editFlyout.overrides.linesToSampleErrorMessage', { defaultMessage: 'Value must be greater than {min} and less than or equal to {max}', values: { @@ -63,7 +63,7 @@ class OverridesUI extends Component { ); customTimestampFormatErrors = i18n.translate( - 'xpack.ml.fileDatavisualizer.editFlyout.overrides.customTimestampFormatErrorMessage', + 'xpack.fileDataVisualizer.editFlyout.overrides.customTimestampFormatErrorMessage', { defaultMessage: `Timestamp format must be a combination of these Java date/time formats: yy, yyyy, M, MM, MMM, MMMM, d, dd, EEE, EEEE, H, HH, h, mm, ss, S through SSSSSSSSS, a, XX, XXX, zzz`, @@ -274,12 +274,9 @@ class OverridesUI extends Component { const timestampFormatHelp = ( - {i18n.translate( - 'xpack.ml.fileDatavisualizer.editFlyout.overrides.timestampFormatHelpText', - { - defaultMessage: 'See more on accepted formats', - } - )} + {i18n.translate('xpack.fileDataVisualizer.editFlyout.overrides.timestampFormatHelpText', { + defaultMessage: 'See more on accepted formats', + })} ); @@ -291,7 +288,7 @@ class OverridesUI extends Component { isInvalid={linesToSampleValid === false} label={ } @@ -306,7 +303,7 @@ class OverridesUI extends Component { } @@ -324,7 +321,7 @@ class OverridesUI extends Component { } @@ -341,7 +338,7 @@ class OverridesUI extends Component { } @@ -353,7 +350,7 @@ class OverridesUI extends Component { } @@ -372,7 +369,7 @@ class OverridesUI extends Component { id={'hasHeaderRow'} label={ } @@ -386,7 +383,7 @@ class OverridesUI extends Component { id={'shouldTrimFields'} label={ } @@ -401,7 +398,7 @@ class OverridesUI extends Component { } @@ -418,7 +415,7 @@ class OverridesUI extends Component { helpText={timestampFormatHelp} label={ } @@ -437,7 +434,7 @@ class OverridesUI extends Component { isInvalid={timestampFormatValid === false} label={ } @@ -453,7 +450,7 @@ class OverridesUI extends Component { } @@ -483,7 +480,7 @@ class OverridesUI extends Component {

diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides.test.js b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides.test.js similarity index 94% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides.test.js rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides.test.js index 764ae6fb2b536..8e11d5150359d 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides.test.js +++ b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides.test.js @@ -10,7 +10,7 @@ import React from 'react'; import { Overrides } from './overrides'; -jest.mock('../../../../../../../../../src/plugins/kibana_react/public', () => ({ +jest.mock('../../../../../../../src/plugins/kibana_react/public', () => ({ withKibana: (comp) => { return comp; }, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides_validation.js b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides_validation.js similarity index 84% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides_validation.js rename to x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides_validation.js index 79a44bd8b5ac6..c833d55351b6d 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/edit_flyout/overrides_validation.js +++ b/x-pack/plugins/file_data_visualizer/public/application/components/edit_flyout/overrides_validation.js @@ -41,7 +41,7 @@ export function isTimestampFormatValid(timestampFormat) { if (timestampFormat.indexOf('?') >= 0) { result.isValid = false; result.errorMessage = i18n.translate( - 'xpack.ml.fileDatavisualizer.editFlyout.overrides.timestampQuestionMarkValidationErrorMessage', + 'xpack.fileDataVisualizer.editFlyout.overrides.timestampQuestionMarkValidationErrorMessage', { defaultMessage: 'Timestamp format {timestampFormat} not supported because it contains a question mark character ({fieldPlaceholder})', @@ -86,7 +86,7 @@ export function isTimestampFormatValid(timestampFormat) { result.isValid = false; result.errorMessage = i18n.translate( - 'xpack.ml.fileDatavisualizer.editFlyout.overrides.timestampLetterValidationErrorMessage', + 'xpack.fileDataVisualizer.editFlyout.overrides.timestampLetterValidationErrorMessage', { defaultMessage: 'Letter { length, plural, one { {lg} } other { group {lg} } } in {format} is not supported', @@ -101,9 +101,10 @@ export function isTimestampFormatValid(timestampFormat) { if (curChar === 'S') { // disable exceeds maximum line length error so i18n check passes result.errorMessage = i18n.translate( - 'xpack.ml.fileDatavisualizer.editFlyout.overrides.timestampLetterSValidationErrorMessage', + 'xpack.fileDataVisualizer.editFlyout.overrides.timestampLetterSValidationErrorMessage', { - defaultMessage: 'Letter { length, plural, one { {lg} } other { group {lg} } } in {format} is not supported because it is not preceded by ss and a separator from {sep}', // eslint-disable-line + defaultMessage: + 'Letter { length, plural, one { {lg} } other { group {lg} } } in {format} is not supported because it is not preceded by ss and a separator from {sep}', // eslint-disable-line values: { length, lg: letterGroup, @@ -127,7 +128,7 @@ export function isTimestampFormatValid(timestampFormat) { if (prevLetterGroup == null) { result.isValid = false; result.errorMessage = i18n.translate( - 'xpack.ml.fileDatavisualizer.editFlyout.overrides.timestampEmptyValidationErrorMessage', + 'xpack.fileDataVisualizer.editFlyout.overrides.timestampEmptyValidationErrorMessage', { defaultMessage: 'No time format letter groups in timestamp format {timestampFormat}', values: { diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/_embedded_map.scss b/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/_embedded_map.scss new file mode 100644 index 0000000000000..99ee60f62bb21 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/_embedded_map.scss @@ -0,0 +1,8 @@ +.embeddedMapContent { + width: 100%; + height: 100%; + display: flex; + flex: 1 1 100%; + z-index: 1; + min-height: 0; // Absolute must for Firefox to scroll contents +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/_index.scss new file mode 100644 index 0000000000000..5b3c6b4990ff1 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/_index.scss @@ -0,0 +1 @@ +@import 'embedded_map'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/embedded_map.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/embedded_map.tsx new file mode 100644 index 0000000000000..42bc5ebf61227 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/embedded_map.tsx @@ -0,0 +1,155 @@ +/* + * 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. + */ + +import React, { useEffect, useRef, useState } from 'react'; + +import { htmlIdGenerator } from '@elastic/eui'; +import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { INITIAL_LOCATION } from '../../../../../maps/common/constants'; +import { + MapEmbeddable, + MapEmbeddableInput, + MapEmbeddableOutput, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../maps/public/embeddable'; +import { MAP_SAVED_OBJECT_TYPE, RenderTooltipContentParams } from '../../../../../maps/public'; +import { + EmbeddableFactory, + ErrorEmbeddable, + isErrorEmbeddable, + ViewMode, +} from '../../../../../../../src/plugins/embeddable/public'; +import { useFileDataVisualizerKibana } from '../../kibana_context'; + +export function EmbeddedMapComponent({ + layerList, + mapEmbeddableInput, + renderTooltipContent, +}: { + layerList: LayerDescriptor[]; + mapEmbeddableInput?: MapEmbeddableInput; + renderTooltipContent?: (params: RenderTooltipContentParams) => JSX.Element; +}) { + const [embeddable, setEmbeddable] = useState(); + + const embeddableRoot: React.RefObject = useRef(null); + const baseLayers = useRef(); + + const { + services: { embeddable: embeddablePlugin, maps: mapsPlugin }, + } = useFileDataVisualizerKibana(); + + const factory: + | EmbeddableFactory + | undefined = embeddablePlugin + ? embeddablePlugin.getEmbeddableFactory(MAP_SAVED_OBJECT_TYPE) + : undefined; + + // Update the layer list with updated geo points upon refresh + useEffect(() => { + async function updateIndexPatternSearchLayer() { + if ( + embeddable && + !isErrorEmbeddable(embeddable) && + Array.isArray(layerList) && + Array.isArray(baseLayers.current) + ) { + embeddable.setLayerList([...baseLayers.current, ...layerList]); + } + } + updateIndexPatternSearchLayer(); + }, [embeddable, layerList]); + + useEffect(() => { + async function setupEmbeddable() { + if (!factory) { + // eslint-disable-next-line no-console + console.error('Map embeddable not found.'); + return; + } + const input: MapEmbeddableInput = { + id: htmlIdGenerator()(), + attributes: { title: '' }, + filters: [], + hidePanelTitles: true, + refreshConfig: { + value: 0, + pause: false, + }, + viewMode: ViewMode.VIEW, + isLayerTOCOpen: false, + hideFilterActions: true, + // can use mapSettings to center map on anomalies + mapSettings: { + disableInteractive: false, + hideToolbarOverlay: false, + hideLayerControl: false, + hideViewControl: false, + initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent + autoFitToDataBounds: true, // this will auto-fit when there are changes to the filter and/or query + }, + }; + + const embeddableObject = await factory.create(input); + + if (embeddableObject && !isErrorEmbeddable(embeddableObject)) { + const basemapLayerDescriptor = mapsPlugin + ? await mapsPlugin.createLayerDescriptors.createBasemapLayerDescriptor() + : null; + + if (basemapLayerDescriptor) { + baseLayers.current = [basemapLayerDescriptor]; + await embeddableObject.setLayerList(baseLayers.current); + } + } + + setEmbeddable(embeddableObject); + } + + setupEmbeddable(); + // we want this effect to execute exactly once after the component mounts + // eslint-disable-next-line + }, []); + + useEffect(() => { + if (embeddable && !isErrorEmbeddable(embeddable) && mapEmbeddableInput !== undefined) { + embeddable.updateInput(mapEmbeddableInput); + } + }, [embeddable, mapEmbeddableInput]); + + useEffect(() => { + if (embeddable && !isErrorEmbeddable(embeddable) && renderTooltipContent !== undefined) { + embeddable.setRenderTooltipContent(renderTooltipContent); + } + }, [embeddable, renderTooltipContent]); + + // We can only render after embeddable has already initialized + useEffect(() => { + if (embeddableRoot.current && embeddable) { + embeddable.render(embeddableRoot.current); + } + }, [embeddable, embeddableRoot]); + + if (!embeddablePlugin) { + // eslint-disable-next-line no-console + console.error('Embeddable start plugin not found'); + return null; + } + if (!mapsPlugin) { + // eslint-disable-next-line no-console + console.error('Maps start plugin not found'); + return null; + } + + return ( +
+ ); +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/index.ts new file mode 100644 index 0000000000000..ee11a18345f64 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/embedded_map/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EmbeddedMapComponent } from './embedded_map'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/examples_list/examples_list.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/examples_list/examples_list.tsx new file mode 100644 index 0000000000000..1c533075af27b --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/examples_list/examples_list.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import { EuiListGroup, EuiListGroupItem } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { ExpandedRowFieldHeader } from '../stats_table/components/expanded_row_field_header'; +interface Props { + examples: Array; +} + +export const ExamplesList: FC = ({ examples }) => { + if (examples === undefined || examples === null || !Array.isArray(examples)) { + return null; + } + let examplesContent; + if (examples.length === 0) { + examplesContent = ( + + ); + } else { + examplesContent = examples.map((example, i) => { + return ( + + ); + }); + } + + return ( +
+ + + + + {examplesContent} + +
+ ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/examples_list/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/examples_list/index.ts new file mode 100644 index 0000000000000..966c844987002 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/examples_list/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExamplesList } from './examples_list'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/file_based_expanded_row.tsx similarity index 69% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/file_based_expanded_row.tsx index 01b5da5c42ccc..620bcfef8ff6c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/file_based_expanded_row.tsx @@ -14,10 +14,10 @@ import { OtherContent, TextContent, NumberContent, -} from '../../../stats_table/components/field_data_expanded_row'; +} from '../stats_table/components/field_data_expanded_row'; import { GeoPointContent } from './geo_point_content/geo_point_content'; -import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; -import type { FileBasedFieldVisConfig } from '../../../stats_table/types/field_vis_config'; +import { JOB_FIELD_TYPES } from '../../../../common'; +import type { FileBasedFieldVisConfig } from '../stats_table/types/field_vis_config'; export const FileBasedDataVisualizerExpandedRow = ({ item }: { item: FileBasedFieldVisConfig }) => { const config = item; @@ -25,25 +25,25 @@ export const FileBasedDataVisualizerExpandedRow = ({ item }: { item: FileBasedFi function getCardContent() { switch (type) { - case ML_JOB_FIELD_TYPES.NUMBER: + case JOB_FIELD_TYPES.NUMBER: return ; - case ML_JOB_FIELD_TYPES.BOOLEAN: + case JOB_FIELD_TYPES.BOOLEAN: return ; - case ML_JOB_FIELD_TYPES.DATE: + case JOB_FIELD_TYPES.DATE: return ; - case ML_JOB_FIELD_TYPES.GEO_POINT: + case JOB_FIELD_TYPES.GEO_POINT: return ; - case ML_JOB_FIELD_TYPES.IP: + case JOB_FIELD_TYPES.IP: return ; - case ML_JOB_FIELD_TYPES.KEYWORD: + case JOB_FIELD_TYPES.KEYWORD: return ; - case ML_JOB_FIELD_TYPES.TEXT: + case JOB_FIELD_TYPES.TEXT: return ; default: @@ -53,7 +53,7 @@ export const FileBasedDataVisualizerExpandedRow = ({ item }: { item: FileBasedFi return (
{getCardContent()} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts b/x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/geo_point_content/format_utils.ts similarity index 96% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/geo_point_content/format_utils.ts index 30e07a6040dab..69e361aba9bca 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/geo_point_content/format_utils.ts @@ -8,7 +8,7 @@ import { Feature, Point } from 'geojson'; import { euiPaletteColorBlind } from '@elastic/eui'; import { DEFAULT_GEO_REGEX } from './geo_point_content'; -import { SOURCE_TYPES } from '../../../../../../../../maps/common/constants'; +import { SOURCE_TYPES } from '../../../../../../maps/common/constants'; export const convertWKTGeoToLonLat = ( value: string | number diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/geo_point_content/geo_point_content.tsx similarity index 78% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/geo_point_content/geo_point_content.tsx index b420ab43f56f4..c395b06059e8f 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/geo_point_content/geo_point_content.tsx @@ -9,12 +9,12 @@ import React, { FC, useMemo } from 'react'; import { EuiFlexItem } from '@elastic/eui'; import { Feature, Point } from 'geojson'; -import type { FieldDataRowProps } from '../../../../stats_table/types/field_data_row'; -import { DocumentStatsTable } from '../../../../stats_table/components/field_data_expanded_row/document_stats'; -import { MlEmbeddedMapComponent } from '../../../../../components/ml_embedded_map'; +import type { FieldDataRowProps } from '../../stats_table/types/field_data_row'; +import { DocumentStatsTable } from '../../stats_table/components/field_data_expanded_row/document_stats'; +import { EmbeddedMapComponent } from '../../embedded_map'; import { convertWKTGeoToLonLat, getGeoPointsLayer } from './format_utils'; -import { ExpandedRowContent } from '../../../../stats_table/components/field_data_expanded_row/expanded_row_content'; -import { ExamplesList } from '../../../../index_based/components/field_data_row/examples_list'; +import { ExpandedRowContent } from '../../stats_table/components/field_data_expanded_row/expanded_row_content'; +import { ExamplesList } from '../../examples_list'; export const DEFAULT_GEO_REGEX = RegExp('(?.+) (?.+)'); @@ -38,7 +38,7 @@ export const GeoPointContent: FC = ({ config }) => { geoPointsFeatures.push({ type: 'Feature', - id: `ml-${config.fieldName}-${i}`, + id: `fileDataVisualizer-${config.fieldName}-${i}`, geometry: { type: 'Point', coordinates: [coordinates.lat, coordinates.lon], @@ -69,10 +69,10 @@ export const GeoPointContent: FC = ({ config }) => { )} {formattedResults && Array.isArray(formattedResults.layerList) && ( - + )} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/geo_point_content/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/geo_point_content/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/expanded_row/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/_experimental_badge.scss b/x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/_experimental_badge.scss similarity index 74% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/_experimental_badge.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/_experimental_badge.scss index 016d5cd579e3f..8b21620542ff7 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/_experimental_badge.scss +++ b/x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/_experimental_badge.scss @@ -1,4 +1,4 @@ -.ml-experimental-badge.euiBetaBadge { +.experimental-badge.euiBetaBadge { font-size: 10px; vertical-align: middle; margin-bottom: 5px; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/_index.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/_index.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/experimental_badge.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/experimental_badge.tsx similarity index 85% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/experimental_badge.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/experimental_badge.tsx index 5eef240429a48..a067cb198914e 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/experimental_badge.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/experimental_badge.tsx @@ -14,10 +14,10 @@ export const ExperimentalBadge: FC<{ tooltipContent: string }> = ({ tooltipConte return ( } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/experimental_badge/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/experimental_badge/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/explanation_flyout/explanation_flyout.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/explanation_flyout/explanation_flyout.tsx similarity index 87% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/explanation_flyout/explanation_flyout.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/explanation_flyout/explanation_flyout.tsx index 579f2e3340954..606bab514ac9f 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/explanation_flyout/explanation_flyout.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/explanation_flyout/explanation_flyout.tsx @@ -20,7 +20,7 @@ import { EuiText, EuiSubSteps, } from '@elastic/eui'; -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; +import { FindFileStructureResponse } from '../../../../../file_upload/common'; interface Props { results: FindFileStructureResponse; @@ -34,7 +34,7 @@ export const ExplanationFlyout: FC = ({ results, closeFlyout }) => {

@@ -48,7 +48,7 @@ export const ExplanationFlyout: FC = ({ results, closeFlyout }) => { @@ -63,7 +63,7 @@ const Content: FC<{ explanation: string[] }> = ({ explanation }) => ( <> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/explanation_flyout/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/explanation_flyout/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/explanation_flyout/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/explanation_flyout/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_data_row/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/field_data_row/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_data_row/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/field_data_row/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_data_row/number_content_preview.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/field_data_row/number_content_preview.tsx similarity index 84% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_data_row/number_content_preview.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/field_data_row/number_content_preview.tsx index dc164b5bf3453..c02976cdb3853 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_data_row/number_content_preview.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/field_data_row/number_content_preview.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { FileBasedFieldVisConfig } from '../../../stats_table/types'; +import { FileBasedFieldVisConfig } from '../stats_table/types'; export const FileBasedNumberContentPreview = ({ config }: { config: FileBasedFieldVisConfig }) => { const stats = config.stats; @@ -25,7 +25,7 @@ export const FileBasedNumberContentPreview = ({ config }: { config: FileBasedFie @@ -33,7 +33,7 @@ export const FileBasedNumberContentPreview = ({ config }: { config: FileBasedFie @@ -41,7 +41,7 @@ export const FileBasedNumberContentPreview = ({ config }: { config: FileBasedFie diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_names_filter/field_names_filter.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/field_names_filter/field_names_filter.tsx similarity index 84% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_names_filter/field_names_filter.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/field_names_filter/field_names_filter.tsx index 9bd16ff5dbefa..466722adc7179 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_names_filter/field_names_filter.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/field_names_filter/field_names_filter.tsx @@ -7,11 +7,11 @@ import React, { FC, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { MultiSelectPicker } from '../../../../components/multi_select_picker'; +import { MultiSelectPicker } from '../multi_select_picker'; import type { FileBasedFieldVisConfig, FileBasedUnknownFieldVisConfig, -} from '../../../stats_table/types/field_vis_config'; +} from '../stats_table/types/field_vis_config'; interface Props { fields: Array; @@ -26,7 +26,7 @@ export const DataVisualizerFieldNamesFilter: FC = ({ }) => { const fieldNameTitle = useMemo( () => - i18n.translate('xpack.ml.dataVisualizer.fileBased.fieldNameSelect', { + i18n.translate('xpack.fileDataVisualizer.fieldNameSelect', { defaultMessage: 'Field name', }), [] diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_names_filter/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/field_names_filter/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_names_filter/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/field_names_filter/index.ts diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap b/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap new file mode 100644 index 0000000000000..769ebdeba9955 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FieldTypeIcon render component when type matches a field type 1`] = ` + + + +`; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/field_type_icon.test.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/field_type_icon.test.tsx new file mode 100644 index 0000000000000..d1321ad8f9f4d --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/field_type_icon.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount, shallow } from 'enzyme'; + +import { FieldTypeIcon } from './field_type_icon'; +import { JOB_FIELD_TYPES } from '../../../../common'; + +describe('FieldTypeIcon', () => { + test(`render component when type matches a field type`, () => { + const typeIconComponent = shallow( + + ); + expect(typeIconComponent).toMatchSnapshot(); + }); + + test(`render with tooltip and test hovering`, () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + const typeIconComponent = mount( + + ); + const container = typeIconComponent.find({ 'data-test-subj': 'fieldTypeIcon' }); + + expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(1); + + container.simulate('mouseover'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + typeIconComponent.update(); + expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(2); + + container.simulate('mouseout'); + + // Run the timers so the EuiTooltip will be hidden again + jest.runAllTimers(); + + typeIconComponent.update(); + expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(1); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); +}); diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/field_type_icon.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/field_type_icon.tsx new file mode 100644 index 0000000000000..2dd7ff635bacd --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/field_type_icon.tsx @@ -0,0 +1,130 @@ +/* + * 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. + */ + +import React, { FC } from 'react'; + +import { EuiToken, EuiToolTip } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { getJobTypeAriaLabel } from '../../util/field_types_utils'; +import { JOB_FIELD_TYPES } from '../../../../common'; +import type { JobFieldType } from '../../../../common'; + +interface FieldTypeIconProps { + tooltipEnabled: boolean; + type: JobFieldType; + fieldName?: string; + needsAria: boolean; +} + +interface FieldTypeIconContainerProps { + ariaLabel: string | null; + iconType: string; + color: string; + needsAria: boolean; + [key: string]: any; +} + +export const FieldTypeIcon: FC = ({ + tooltipEnabled = false, + type, + fieldName, + needsAria = true, +}) => { + const ariaLabel = getJobTypeAriaLabel(type); + + let iconType = 'questionInCircle'; + let color = 'euiColorVis6'; + + switch (type) { + // Set icon types and colors + case JOB_FIELD_TYPES.BOOLEAN: + iconType = 'tokenBoolean'; + color = 'euiColorVis5'; + break; + case JOB_FIELD_TYPES.DATE: + iconType = 'tokenDate'; + color = 'euiColorVis7'; + break; + case JOB_FIELD_TYPES.GEO_POINT: + case JOB_FIELD_TYPES.GEO_SHAPE: + iconType = 'tokenGeo'; + color = 'euiColorVis8'; + break; + case JOB_FIELD_TYPES.TEXT: + iconType = 'document'; + color = 'euiColorVis9'; + break; + case JOB_FIELD_TYPES.IP: + iconType = 'tokenIP'; + color = 'euiColorVis3'; + break; + case JOB_FIELD_TYPES.KEYWORD: + iconType = 'tokenText'; + color = 'euiColorVis0'; + break; + case JOB_FIELD_TYPES.NUMBER: + iconType = 'tokenNumber'; + color = fieldName !== undefined ? 'euiColorVis1' : 'euiColorVis2'; + break; + case JOB_FIELD_TYPES.UNKNOWN: + // Use defaults + break; + } + + const containerProps = { + ariaLabel, + iconType, + color, + needsAria, + }; + + if (tooltipEnabled === true) { + // wrap the inner component inside because EuiToolTip doesn't seem + // to support having another component directly inside the tooltip anchor + // see https://github.com/elastic/eui/issues/839 + return ( + + + + ); + } + + return ; +}; + +// If the tooltip is used, it will apply its events to its first inner child. +// To pass on its properties we apply `rest` to the outer `span` element. +const FieldTypeIconContainer: FC = ({ + ariaLabel, + iconType, + color, + needsAria, + ...rest +}) => { + const wrapperProps: { className: string; 'aria-label'?: string } = { + className: 'field-type-icon', + }; + if (needsAria && ariaLabel) { + wrapperProps['aria-label'] = ariaLabel; + } + + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/index.ts new file mode 100644 index 0000000000000..fa825e447be30 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/field_type_icon/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { FieldTypeIcon } from './field_type_icon'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_types_filter/field_types_filter.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/field_types_filter/field_types_filter.tsx similarity index 65% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_types_filter/field_types_filter.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/field_types_filter/field_types_filter.tsx index 6ad6cfc84061d..8c5602bc625f8 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_types_filter/field_types_filter.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/field_types_filter/field_types_filter.tsx @@ -8,13 +8,25 @@ import React, { FC, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { MultiSelectPicker, Option } from '../../../../components/multi_select_picker'; +import { MultiSelectPicker, Option } from '../multi_select_picker'; import type { FileBasedFieldVisConfig, FileBasedUnknownFieldVisConfig, -} from '../../../stats_table/types/field_vis_config'; -import { FieldTypeIcon } from '../../../../components/field_type_icon'; -import { ML_JOB_FIELD_TYPES_OPTIONS } from '../../../index_based/components/search_panel/field_type_filter'; +} from '../stats_table/types/field_vis_config'; +import { FieldTypeIcon } from '../field_type_icon'; +import { JOB_FIELD_TYPES } from '../../../../common'; + +const JOB_FIELD_TYPES_OPTIONS = { + [JOB_FIELD_TYPES.BOOLEAN]: { name: 'Boolean', icon: 'tokenBoolean' }, + [JOB_FIELD_TYPES.DATE]: { name: 'Date', icon: 'tokenDate' }, + [JOB_FIELD_TYPES.GEO_POINT]: { name: 'Geo point', icon: 'tokenGeo' }, + [JOB_FIELD_TYPES.GEO_SHAPE]: { name: 'Geo shape', icon: 'tokenGeo' }, + [JOB_FIELD_TYPES.IP]: { name: 'IP address', icon: 'tokenIP' }, + [JOB_FIELD_TYPES.KEYWORD]: { name: 'Keyword', icon: 'tokenKeyword' }, + [JOB_FIELD_TYPES.NUMBER]: { name: 'Number', icon: 'tokenNumber' }, + [JOB_FIELD_TYPES.TEXT]: { name: 'Text', icon: 'tokenString' }, + [JOB_FIELD_TYPES.UNKNOWN]: { name: 'Unknown' }, +}; interface Props { fields: Array; @@ -29,7 +41,7 @@ export const DataVisualizerFieldTypesFilter: FC = ({ }) => { const fieldNameTitle = useMemo( () => - i18n.translate('xpack.ml.dataVisualizer.fileBased.fieldTypeSelect', { + i18n.translate('xpack.fileDataVisualizer.fieldTypeSelect', { defaultMessage: 'Field type', }), [] @@ -42,9 +54,9 @@ export const DataVisualizerFieldTypesFilter: FC = ({ if ( type !== undefined && !fieldTypesTracker.has(type) && - ML_JOB_FIELD_TYPES_OPTIONS[type] !== undefined + JOB_FIELD_TYPES_OPTIONS[type] !== undefined ) { - const item = ML_JOB_FIELD_TYPES_OPTIONS[type]; + const item = JOB_FIELD_TYPES_OPTIONS[type]; fieldTypesTracker.add(type); fieldTypes.push({ diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_types_filter/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/field_types_filter/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/field_types_filter/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/field_types_filter/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/create_fields.ts b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/create_fields.ts similarity index 80% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/create_fields.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/create_fields.ts index fdbb35d27c531..f45071d6e96b5 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/create_fields.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/create_fields.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; +import { FindFileStructureResponse } from '../../../../../file_upload/common'; import { getFieldNames, getSupportedFieldType } from './get_field_names'; -import { FileBasedFieldVisConfig } from '../../../stats_table/types'; -import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; -import { roundToDecimalPlace } from '../../../../formatters/round_to_decimal_place'; +import { FileBasedFieldVisConfig } from '../stats_table/types'; +import { JOB_FIELD_TYPES } from '../../../../common'; +import { roundToDecimalPlace } from '../utils'; export function createFields(results: FindFileStructureResponse) { const { @@ -28,20 +28,20 @@ export function createFields(results: FindFileStructureResponse) { if (fieldStats[name] !== undefined) { const field: FileBasedFieldVisConfig = { fieldName: name, - type: ML_JOB_FIELD_TYPES.UNKNOWN, + type: JOB_FIELD_TYPES.UNKNOWN, }; const f = fieldStats[name]; const m = mappings.properties[name]; // sometimes the timestamp field is not in the mappings, and so our // collection of fields will be missing a time field with a type of date - if (name === timestampField && field.type === ML_JOB_FIELD_TYPES.UNKNOWN) { - field.type = ML_JOB_FIELD_TYPES.DATE; + if (name === timestampField && field.type === JOB_FIELD_TYPES.UNKNOWN) { + field.type = JOB_FIELD_TYPES.DATE; } if (m !== undefined) { field.type = getSupportedFieldType(m.type); - if (field.type === ML_JOB_FIELD_TYPES.NUMBER) { + if (field.type === JOB_FIELD_TYPES.NUMBER) { numericFieldsCount += 1; } if (m.format !== undefined) { @@ -71,7 +71,7 @@ export function createFields(results: FindFileStructureResponse) { } if (f.top_hits !== undefined) { - if (field.type === ML_JOB_FIELD_TYPES.TEXT) { + if (field.type === JOB_FIELD_TYPES.TEXT) { _stats = { ..._stats, examples: f.top_hits.map((hit) => hit.value), @@ -84,7 +84,7 @@ export function createFields(results: FindFileStructureResponse) { } } - if (field.type === ML_JOB_FIELD_TYPES.DATE) { + if (field.type === JOB_FIELD_TYPES.DATE) { _stats = { ..._stats, earliest: f.earliest, @@ -99,9 +99,9 @@ export function createFields(results: FindFileStructureResponse) { // this could be the message field for a semi-structured log file or a // field which the endpoint has not been able to work out any information for const type = - mappings.properties[name] && mappings.properties[name].type === ML_JOB_FIELD_TYPES.TEXT - ? ML_JOB_FIELD_TYPES.TEXT - : ML_JOB_FIELD_TYPES.UNKNOWN; + mappings.properties[name] && mappings.properties[name].type === JOB_FIELD_TYPES.TEXT + ? JOB_FIELD_TYPES.TEXT + : JOB_FIELD_TYPES.UNKNOWN; return { fieldName: name, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/fields_stats_grid.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/fields_stats_grid.tsx similarity index 79% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/fields_stats_grid.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/fields_stats_grid.tsx index 1029d58b4c639..3b5b1bbf81dba 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/fields_stats_grid.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/fields_stats_grid.tsx @@ -5,29 +5,25 @@ * 2.0. */ -import React, { useMemo, FC } from 'react'; +import React, { useMemo, FC, useState } from 'react'; import { EuiFlexGroup, EuiSpacer } from '@elastic/eui'; -import type { FindFileStructureResponse } from '../../../../../../../file_upload/common'; -import { DataVisualizerTable, ItemIdToExpandedRowMap } from '../../../stats_table'; -import type { FileBasedFieldVisConfig } from '../../../stats_table/types/field_vis_config'; +import type { FindFileStructureResponse } from '../../../../../file_upload/common'; +import type { DataVisualizerTableState } from '../../../../common'; +import { DataVisualizerTable, ItemIdToExpandedRowMap } from '../stats_table'; +import type { FileBasedFieldVisConfig } from '../stats_table/types/field_vis_config'; import { FileBasedDataVisualizerExpandedRow } from '../expanded_row'; import { DataVisualizerFieldNamesFilter } from '../field_names_filter'; import { DataVisualizerFieldTypesFilter } from '../field_types_filter'; import { createFields } from './create_fields'; import { filterFields } from './filter_fields'; -import { usePageUrlState } from '../../../../util/url_state'; -import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; -import { - MetricFieldsCount, - TotalFieldsCount, -} from '../../../stats_table/components/field_count_stats'; -import type { DataVisualizerFileBasedAppState } from '../../../../../../common/types/ml_url_generator'; +import { MetricFieldsCount, TotalFieldsCount } from '../stats_table/components/field_count_stats'; interface Props { results: FindFileStructureResponse; } -export const getDefaultDataVisualizerListState = (): Required => ({ + +export const getDefaultDataVisualizerListState = (): DataVisualizerTableState => ({ pageIndex: 0, pageSize: 10, sortField: 'fieldName', @@ -52,13 +48,11 @@ function getItemIdToExpandedRowMap( export const FieldsStatsGrid: FC = ({ results }) => { const restorableDefaults = getDefaultDataVisualizerListState(); - const [ - dataVisualizerListState, - setDataVisualizerListState, - ] = usePageUrlState( - ML_PAGES.DATA_VISUALIZER_FILE, + + const [dataVisualizerListState, setDataVisualizerListState] = useState( restorableDefaults ); + const visibleFieldTypes = dataVisualizerListState.visibleFieldTypes ?? restorableDefaults.visibleFieldTypes; const setVisibleFieldTypes = (values: string[]) => { @@ -73,11 +67,11 @@ export const FieldsStatsGrid: FC = ({ results }) => { const { fields, totalFieldsCount, totalMetricFieldsCount } = useMemo( () => createFields(results), - [results, visibleFieldNames, visibleFieldTypes] + [results] ); const { filteredFields, visibleFieldsCount, visibleMetricsCount } = useMemo( () => filterFields(fields, visibleFieldNames, visibleFieldTypes), - [results, visibleFieldNames, visibleFieldTypes] + [fields, visibleFieldNames, visibleFieldTypes] ); const fieldsCountStats = { visibleFieldsCount, totalFieldsCount }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/filter_fields.ts b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/filter_fields.ts similarity index 81% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/filter_fields.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/filter_fields.ts index 2c43d11c3d447..0120b17452558 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/filter_fields.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/filter_fields.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; +import { JOB_FIELD_TYPES } from '../../../../common'; import type { FileBasedFieldVisConfig, FileBasedUnknownFieldVisConfig, -} from '../../../stats_table/types/field_vis_config'; +} from '../stats_table/types/field_vis_config'; export function filterFields( fields: Array, @@ -32,6 +32,6 @@ export function filterFields( return { filteredFields: items, visibleFieldsCount: items.length, - visibleMetricsCount: items.filter((d) => d.type === ML_JOB_FIELD_TYPES.NUMBER).length, + visibleMetricsCount: items.filter((d) => d.type === JOB_FIELD_TYPES.NUMBER).length, }; } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/get_field_names.ts b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/get_field_names.ts similarity index 75% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/get_field_names.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/get_field_names.ts index d1cb361a84a72..83c517dfe965e 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/get_field_names.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/get_field_names.ts @@ -6,10 +6,10 @@ */ import { difference } from 'lodash'; -import type { FindFileStructureResponse } from '../../../../../../../file_upload/common'; -import { MlJobFieldType } from '../../../../../../common/types/field_types'; -import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; -import { ES_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/common'; +import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/common'; +import type { FindFileStructureResponse } from '../../../../../file_upload/common'; +import type { JobFieldType } from '../../../../common'; +import { JOB_FIELD_TYPES } from '../../../../common'; export function getFieldNames(results: FindFileStructureResponse) { const { mappings, field_stats: fieldStats, column_names: columnNames } = results; @@ -34,7 +34,7 @@ export function getFieldNames(results: FindFileStructureResponse) { return tempFields; } -export function getSupportedFieldType(type: string): MlJobFieldType { +export function getSupportedFieldType(type: string): JobFieldType { switch (type) { case ES_FIELD_TYPES.FLOAT: case ES_FIELD_TYPES.HALF_FLOAT: @@ -44,13 +44,13 @@ export function getSupportedFieldType(type: string): MlJobFieldType { case ES_FIELD_TYPES.LONG: case ES_FIELD_TYPES.SHORT: case ES_FIELD_TYPES.UNSIGNED_LONG: - return ML_JOB_FIELD_TYPES.NUMBER; + return JOB_FIELD_TYPES.NUMBER; case ES_FIELD_TYPES.DATE: case ES_FIELD_TYPES.DATE_NANOS: - return ML_JOB_FIELD_TYPES.DATE; + return JOB_FIELD_TYPES.DATE; default: - return type as MlJobFieldType; + return type as JobFieldType; } } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats_grid/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/fields_stats_grid/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_contents/_file_contents.scss b/x-pack/plugins/file_data_visualizer/public/application/components/file_contents/_file_contents.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_contents/_file_contents.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/file_contents/_file_contents.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_contents/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/file_contents/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_contents/_index.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/file_contents/_index.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_contents/file_contents.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/file_contents/file_contents.tsx similarity index 78% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_contents/file_contents.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/file_contents/file_contents.tsx index 3de8e5851183d..fa54cf9cbc05c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_contents/file_contents.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/file_contents/file_contents.tsx @@ -10,7 +10,7 @@ import React, { FC } from 'react'; import { EuiTitle, EuiSpacer } from '@elastic/eui'; -import { MLJobEditor, ML_EDITOR_MODE } from '../../../../jobs/jobs_list/components/ml_job_editor'; +import { JsonEditor, EDITOR_MODE } from '../json_editor'; interface Props { data: string; @@ -19,9 +19,9 @@ interface Props { } export const FileContents: FC = ({ data, format, numberOfLines }) => { - let mode = ML_EDITOR_MODE.TEXT; - if (format === ML_EDITOR_MODE.JSON) { - mode = ML_EDITOR_MODE.JSON; + let mode = EDITOR_MODE.TEXT; + if (format === EDITOR_MODE.JSON) { + mode = EDITOR_MODE.JSON; } const formattedData = limitByNumberOfLines(data, numberOfLines); @@ -31,7 +31,7 @@ export const FileContents: FC = ({ data, format, numberOfLines }) => {

@@ -39,7 +39,7 @@ export const FileContents: FC = ({ data, format, numberOfLines }) => {
= ({ data, format, numberOfLines }) => { - ), @@ -345,9 +345,10 @@ export class FileDataVisualizerView extends Component { fileContents={fileContents} data={data} indexPatterns={this.props.indexPatterns} - kibanaConfig={this.props.kibanaConfig} showBottomBar={this.showBottomBar} hideBottomBar={this.hideBottomBar} + savedObjectsClient={this.savedObjectsClient} + fileUpload={this.props.fileUpload} /> {bottomBarVisible && ( diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/file_error_callouts.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/file_datavisualizer_view/file_error_callouts.tsx similarity index 79% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/file_error_callouts.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/file_datavisualizer_view/file_error_callouts.tsx index 0fa7de4732c39..b932dee35ebb8 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/file_error_callouts.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/file_datavisualizer_view/file_error_callouts.tsx @@ -11,8 +11,8 @@ import React, { FC } from 'react'; import { EuiCallOut, EuiSpacer, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui'; import numeral from '@elastic/numeral'; -import { ErrorResponse } from '../../../../../../common/types/errors'; -import { FILE_SIZE_DISPLAY_FORMAT } from '../../../../../../../file_upload/public'; +import { FILE_SIZE_DISPLAY_FORMAT } from '../../../../common'; +import { FindFileStructureErrorResponse } from '../../../../../file_upload/common'; interface FileTooLargeProps { fileSize: number; @@ -31,7 +31,7 @@ export const FileTooLarge: FC = ({ fileSize, maxFileSize }) = errorText = (

= ({ fileSize, maxFileSize }) = errorText = (

= ({ fileSize, maxFileSize }) = } @@ -76,7 +76,7 @@ export const FileTooLarge: FC = ({ fileSize, maxFileSize }) = }; interface FileCouldNotBeReadProps { - error: ErrorResponse; + error: FindFileStructureErrorResponse; loaded: boolean; showEditFlyout(): void; } @@ -92,7 +92,7 @@ export const FileCouldNotBeRead: FC = ({ } @@ -103,13 +103,13 @@ export const FileCouldNotBeRead: FC = ({ {loaded === false && ( <>
@@ -122,7 +122,7 @@ export const FileCouldNotBeRead: FC = ({ <> @@ -132,11 +132,11 @@ export const FileCouldNotBeRead: FC = ({ ); }; -export const Explanation: FC<{ error: ErrorResponse }> = ({ error }) => { +export const Explanation: FC<{ error: FindFileStructureErrorResponse }> = ({ error }) => { if (!error?.body?.attributes?.body?.error?.suppressed?.length) { return null; } - const reason: string = error.body.attributes.body.error.suppressed[0].reason; + const reason = error.body.attributes.body.error.suppressed[0].reason; return ( <> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/index.js b/x-pack/plugins/file_data_visualizer/public/application/components/file_datavisualizer_view/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/index.js rename to x-pack/plugins/file_data_visualizer/public/application/components/file_datavisualizer_view/index.js diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config.ts b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config.ts similarity index 91% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config.ts index 2254110432bdb..1cbb177c86442 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; +import { FindFileStructureResponse } from '../../../../../file_upload/common'; export function createFilebeatConfig( index: string, @@ -36,7 +36,7 @@ export function createFilebeatConfig( } function getPaths() { - const txt = i18n.translate('xpack.ml.fileDatavisualizer.fileBeatConfig.paths', { + const txt = i18n.translate('xpack.fileDataVisualizer.fileBeatConfig.paths', { defaultMessage: 'add path to your files here', }); return [' paths:', ` - '<${txt}>'`]; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx similarity index 83% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx index c3b53d4430087..a5d05bb06f78e 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -22,8 +22,8 @@ import { EuiCopy, } from '@elastic/eui'; import { createFilebeatConfig } from './filebeat_config'; -import { useMlKibana } from '../../../../contexts/kibana'; -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; +import { useFileDataVisualizerKibana } from '../../kibana_context'; // copy context? +import { FindFileStructureResponse } from '../../../../../file_upload/common'; export enum EDITOR_MODE { HIDDEN, @@ -48,7 +48,7 @@ export const FilebeatConfigFlyout: FC = ({ const [username, setUsername] = useState(null); const { services: { security }, - } = useMlKibana(); + } = useFileDataVisualizerKibana(); useEffect(() => { if (security !== undefined) { @@ -56,12 +56,12 @@ export const FilebeatConfigFlyout: FC = ({ setUsername(user.username === undefined ? null : user.username); }); } - }, []); + }, [security]); useEffect(() => { const config = createFilebeatConfig(index, results, ingestPipelineId, username); setFileBeatConfig(config); - }, [username]); + }, [username, index, ingestPipelineId, results]); return ( @@ -75,7 +75,7 @@ export const FilebeatConfigFlyout: FC = ({ @@ -85,7 +85,7 @@ export const FilebeatConfigFlyout: FC = ({ {(copy) => ( @@ -108,7 +108,7 @@ const Contents: FC<{

@@ -116,14 +116,14 @@ const Contents: FC<{

{index} }} />

filebeat.yml }} /> @@ -137,7 +137,7 @@ const Contents: FC<{

{username === null ? ( {''}, @@ -145,7 +145,7 @@ const Contents: FC<{ /> ) : ( {username}, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/errors.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/import_errors/errors.tsx similarity index 81% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/errors.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/import_errors/errors.tsx index 37e90b5f5753b..5a6f78a1a3068 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/errors.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/import_errors/errors.tsx @@ -38,56 +38,56 @@ function title(statuses: Statuses) { case statuses.readStatus: return ( ); case statuses.parseJSONStatus: return ( ); case statuses.indexCreatedStatus: return ( ); case statuses.ingestPipelineCreatedStatus: return ( ); case statuses.uploadStatus: return ( ); case statuses.indexPatternCreatedStatus: return ( ); case statuses.permissionCheckStatus: return ( ); default: return ( ); @@ -105,7 +105,7 @@ const ImportError: FC<{ error: any }> = ({ error }) => { id="more" buttonContent={ } @@ -151,7 +151,7 @@ function toString(error: any): ImportError { } return { - msg: i18n.translate('xpack.ml.fileDatavisualizer.importErrors.unknownErrorMessage', { + msg: i18n.translate('xpack.fileDataVisualizer.importErrors.unknownErrorMessage', { defaultMessage: 'Unknown error', }), }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/import_errors/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/import_errors/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_progress/import_progress.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/import_progress/import_progress.tsx similarity index 81% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_progress/import_progress.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/import_progress/import_progress.tsx index 40577a761cb03..8296a4885bf2c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_progress/import_progress.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/import_progress/import_progress.tsx @@ -80,31 +80,31 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { } let processFileTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.processFileTitle', + 'xpack.fileDataVisualizer.importProgress.processFileTitle', { defaultMessage: 'Process file', } ); let createIndexTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.createIndexTitle', + 'xpack.fileDataVisualizer.importProgress.createIndexTitle', { defaultMessage: 'Create index', } ); let createIngestPipelineTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.createIngestPipelineTitle', + 'xpack.fileDataVisualizer.importProgress.createIngestPipelineTitle', { defaultMessage: 'Create ingest pipeline', } ); let uploadingDataTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.uploadDataTitle', + 'xpack.fileDataVisualizer.importProgress.uploadDataTitle', { defaultMessage: 'Upload data', } ); let createIndexPatternTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.createIndexPatternTitle', + 'xpack.fileDataVisualizer.importProgress.createIndexPatternTitle', { defaultMessage: 'Create index pattern', } @@ -113,7 +113,7 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { const creatingIndexStatus = (

@@ -122,7 +122,7 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { const creatingIndexAndIngestPipelineStatus = (

@@ -130,7 +130,7 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { if (completedStep >= 0) { processFileTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.processingFileTitle', + 'xpack.fileDataVisualizer.importProgress.processingFileTitle', { defaultMessage: 'Processing file', } @@ -138,7 +138,7 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { statusInfo = (

@@ -146,13 +146,13 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { } if (completedStep >= 1) { processFileTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.fileProcessedTitle', + 'xpack.fileDataVisualizer.importProgress.fileProcessedTitle', { defaultMessage: 'File processed', } ); createIndexTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.creatingIndexTitle', + 'xpack.fileDataVisualizer.importProgress.creatingIndexTitle', { defaultMessage: 'Creating index', } @@ -161,14 +161,11 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { createPipeline === true ? creatingIndexAndIngestPipelineStatus : creatingIndexStatus; } if (completedStep >= 2) { - createIndexTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.indexCreatedTitle', - { - defaultMessage: 'Index created', - } - ); + createIndexTitle = i18n.translate('xpack.fileDataVisualizer.importProgress.indexCreatedTitle', { + defaultMessage: 'Index created', + }); createIngestPipelineTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.creatingIngestPipelineTitle', + 'xpack.fileDataVisualizer.importProgress.creatingIngestPipelineTitle', { defaultMessage: 'Creating ingest pipeline', } @@ -178,13 +175,13 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { } if (completedStep >= 3) { createIngestPipelineTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.ingestPipelineCreatedTitle', + 'xpack.fileDataVisualizer.importProgress.ingestPipelineCreatedTitle', { defaultMessage: 'Ingest pipeline created', } ); uploadingDataTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.uploadingDataTitle', + 'xpack.fileDataVisualizer.importProgress.uploadingDataTitle', { defaultMessage: 'Uploading data', } @@ -193,14 +190,14 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { } if (completedStep >= 4) { uploadingDataTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.dataUploadedTitle', + 'xpack.fileDataVisualizer.importProgress.dataUploadedTitle', { defaultMessage: 'Data uploaded', } ); if (createIndexPattern === true) { createIndexPatternTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.creatingIndexPatternTitle', + 'xpack.fileDataVisualizer.importProgress.creatingIndexPatternTitle', { defaultMessage: 'Creating index pattern', } @@ -208,7 +205,7 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { statusInfo = (

@@ -219,7 +216,7 @@ export const ImportProgress: FC<{ statuses: Statuses }> = ({ statuses }) => { } if (completedStep >= 5) { createIndexPatternTitle = i18n.translate( - 'xpack.ml.fileDatavisualizer.importProgress.indexPatternCreatedTitle', + 'xpack.fileDataVisualizer.importProgress.indexPatternCreatedTitle', { defaultMessage: 'Index pattern created', } @@ -293,7 +290,7 @@ const UploadFunctionProgress: FC<{ progress: number }> = ({ progress }) => {

diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_progress/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/import_progress/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_progress/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/import_progress/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/advanced.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/import_settings/advanced.tsx similarity index 83% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/advanced.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/import_settings/advanced.tsx index eb0e09973f0e3..acb6415e93f9b 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/advanced.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/import_settings/advanced.tsx @@ -19,8 +19,8 @@ import { } from '@elastic/eui'; import { CombinedField, CombinedFieldsForm } from '../combined_fields'; -import { MLJobEditor, ML_EDITOR_MODE } from '../../../../jobs/jobs_list/components/ml_job_editor'; -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; +import { JsonEditor, EDITOR_MODE } from '../json_editor'; +import { FindFileStructureResponse } from '../../../../../file_upload/common'; const EDITOR_HEIGHT = '300px'; interface Props { @@ -35,8 +35,8 @@ interface Props { mappingsString: string; pipelineString: string; onIndexSettingsStringChange(): void; - onMappingsStringChange(): void; - onPipelineStringChange(): void; + onMappingsStringChange(mappings: string): void; + onPipelineStringChange(pipeline: string): void; indexNameError: string; indexPatternNameError: string; combinedFields: CombinedField[]; @@ -69,7 +69,7 @@ export const AdvancedSettings: FC = ({ } @@ -78,7 +78,7 @@ export const AdvancedSettings: FC = ({ > = ({ onChange={onIndexChange} isInvalid={indexNameError !== ''} aria-label={i18n.translate( - 'xpack.ml.fileDatavisualizer.advancedImportSettings.indexNameAriaLabel', + 'xpack.fileDataVisualizer.advancedImportSettings.indexNameAriaLabel', { defaultMessage: 'Index name, required field', } @@ -102,7 +102,7 @@ export const AdvancedSettings: FC = ({ id="createIndexPattern" label={ } @@ -116,7 +116,7 @@ export const AdvancedSettings: FC = ({ } @@ -175,7 +175,7 @@ export const AdvancedSettings: FC = ({ interface JsonEditorProps { initialized: boolean; data: string; - onChange(): void; + onChange(value: string): void; } const IndexSettings: FC = ({ initialized, data, onChange }) => { @@ -184,14 +184,14 @@ const IndexSettings: FC = ({ initialized, data, onChange }) => } fullWidth > - = ({ initialized, data, onChange }) => { } fullWidth > - = ({ initialized, data, onChange }) => } fullWidth > - = ({ const tabs = [ { id: 'simple-settings', - name: i18n.translate('xpack.ml.fileDatavisualizer.importSettings.simpleTabName', { + name: i18n.translate('xpack.fileDataVisualizer.importSettings.simpleTabName', { defaultMessage: 'Simple', }), content: ( @@ -80,7 +80,7 @@ export const ImportSettings: FC = ({ }, { id: 'advanced-settings', - name: i18n.translate('xpack.ml.fileDatavisualizer.importSettings.advancedTabName', { + name: i18n.translate('xpack.fileDataVisualizer.importSettings.advancedTabName', { defaultMessage: 'Advanced', }), content: ( diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/import_settings/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/import_settings/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/import_settings/simple.tsx similarity index 86% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/import_settings/simple.tsx index daa360f0e1af0..2751b37cd3256 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/import_settings/simple.tsx @@ -36,7 +36,7 @@ export const SimpleSettings: FC = ({ } @@ -45,7 +45,7 @@ export const SimpleSettings: FC = ({ > = ({ onChange={onIndexChange} isInvalid={indexNameError !== ''} aria-label={i18n.translate( - 'xpack.ml.fileDatavisualizer.simpleImportSettings.indexNameAriaLabel', + 'xpack.fileDataVisualizer.simpleImportSettings.indexNameAriaLabel', { defaultMessage: 'Index name, required field', } @@ -70,7 +70,7 @@ export const SimpleSettings: FC = ({ id="createIndexPattern" label={ } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/_import_sumary.scss b/x-pack/plugins/file_data_visualizer/public/application/components/import_summary/_import_sumary.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/_import_sumary.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/import_summary/_import_sumary.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/import_summary/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/_index.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/import_summary/_index.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/failures.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/import_summary/failures.tsx similarity index 95% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/failures.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/import_summary/failures.tsx index 498320b1b792d..c8f62021b7bae 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/failures.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/import_summary/failures.tsx @@ -51,7 +51,7 @@ export class Failures extends Component { id="failureList" buttonContent={ } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/import_summary.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/import_summary/import_summary.tsx similarity index 84% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/import_summary.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/import_summary/import_summary.tsx index 7fa71193ee516..f981b1fdf9f23 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/import_summary.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/import_summary/import_summary.tsx @@ -45,7 +45,7 @@ export const ImportSummary: FC = ({ } @@ -62,7 +62,7 @@ export const ImportSummary: FC = ({ } @@ -71,7 +71,7 @@ export const ImportSummary: FC = ({ >

), @@ -111,7 +111,7 @@ function createDisplayItems( { title: ( ), @@ -123,7 +123,7 @@ function createDisplayItems( items.splice(1, 0, { title: ( ), @@ -135,7 +135,7 @@ function createDisplayItems( items.splice(1, 0, { title: ( ), @@ -147,7 +147,7 @@ function createDisplayItems( items.push({ title: ( ), diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/import_summary/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/import_summary/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/plugins/file_data_visualizer/public/application/components/import_view/import_view.js similarity index 92% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js rename to x-pack/plugins/file_data_visualizer/public/application/components/import_view/import_view.js index 04175f46c9201..0eaba4c033910 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/plugins/file_data_visualizer/public/application/components/import_view/import_view.js @@ -20,7 +20,6 @@ import { import { i18n } from '@kbn/i18n'; import { debounce } from 'lodash'; -import { getFileUpload } from '../../../../util/dependency_cache'; import { ResultsLinks } from '../results_links'; import { FilebeatConfigFlyout } from '../filebeat_config_flyout'; import { ImportProgress, IMPORT_STATUS } from '../import_progress'; @@ -33,8 +32,6 @@ import { getDefaultCombinedFields, } from '../combined_fields'; import { ExperimentalBadge } from '../experimental_badge'; -import { getIndexPatternNames, loadIndexPatterns } from '../../../../util/index_utils'; -import { ml } from '../../../../services/ml_api_service'; const DEFAULT_TIME_FIELD = '@timestamp'; const DEFAULT_INDEX_SETTINGS = { number_of_shards: 1 }; @@ -81,6 +78,7 @@ export class ImportView extends Component { super(props); this.state = getDefaultState(DEFAULT_STATE, this.props.results); + this.savedObjectsClient = props.savedObjectsClient; } componentDidMount() { @@ -100,7 +98,7 @@ export class ImportView extends Component { // TODO - sort this function out. it's a mess async import() { - const { data, results, indexPatterns, kibanaConfig, showBottomBar } = this.props; + const { data, results, indexPatterns, showBottomBar, fileUpload } = this.props; const { format } = results; let { timeFieldName } = this.state; @@ -124,14 +122,14 @@ export class ImportView extends Component { async () => { // check to see if the user has permission to create and ingest data into the specified index if ( - (await getFileUpload().hasImportPermission({ + (await fileUpload.hasImportPermission({ checkCreateIndexPattern: createIndexPattern, checkHasManagePipeline: true, indexName: index, })) === false ) { errors.push( - i18n.translate('xpack.ml.fileDatavisualizer.importView.importPermissionError', { + i18n.translate('xpack.fileDataVisualizer.importView.importPermissionError', { defaultMessage: 'You do not have permission to create or import data into index {index}.', values: { @@ -171,7 +169,7 @@ export class ImportView extends Component { } catch (error) { success = false; const parseError = i18n.translate( - 'xpack.ml.fileDatavisualizer.importView.parseSettingsError', + 'xpack.fileDataVisualizer.importView.parseSettingsError', { defaultMessage: 'Error parsing settings:', } @@ -184,7 +182,7 @@ export class ImportView extends Component { } catch (error) { success = false; const parseError = i18n.translate( - 'xpack.ml.fileDatavisualizer.importView.parseMappingsError', + 'xpack.fileDataVisualizer.importView.parseMappingsError', { defaultMessage: 'Error parsing mappings:', } @@ -199,7 +197,7 @@ export class ImportView extends Component { } catch (error) { success = false; const parseError = i18n.translate( - 'xpack.ml.fileDatavisualizer.importView.parsePipelineError', + 'xpack.fileDataVisualizer.importView.parsePipelineError', { defaultMessage: 'Error parsing ingest pipeline:', } @@ -221,7 +219,7 @@ export class ImportView extends Component { } if (success) { - const importer = await getFileUpload().importerFactory(format, { + const importer = await fileUpload.importerFactory(format, { excludeLinesPattern: results.exclude_lines_pattern, multilineStartPattern: results.multiline_start_pattern, }); @@ -294,8 +292,7 @@ export class ImportView extends Component { const indexPatternResp = await createKibanaIndexPattern( indexPatternName, indexPatterns, - timeFieldName, - kibanaConfig + timeFieldName ); success = indexPatternResp.success; this.setState({ @@ -354,16 +351,15 @@ export class ImportView extends Component { return; } - const { exists } = await ml.checkIndexExists({ index }); + const exists = await this.props.fileUpload.checkIndexExists(index); const indexNameError = exists ? ( ) : ( isIndexNameValid(index) ); - this.setState({ checkingValidIndex: false, indexNameError }); }, 500); @@ -427,9 +423,19 @@ export class ImportView extends Component { }; async loadIndexPatternNames() { - await loadIndexPatterns(this.props.indexPatterns); - const indexPatternNames = getIndexPatternNames(); - this.setState({ indexPatternNames }); + try { + const indexPatternNames = ( + await this.savedObjectsClient.find({ + type: 'index-pattern', + fields: ['title'], + perPage: 10000, + }) + ).savedObjects.map(({ attributes }) => attributes && attributes.title); + + this.setState({ indexPatternNames }); + } catch (error) { + console.error('failed to load index patterns', error); + } } render() { @@ -501,14 +507,14 @@ export class ImportView extends Component {

  } @@ -549,7 +555,7 @@ export class ImportView extends Component { data-test-subj="mlFileDataVisImportButton" > @@ -558,7 +564,7 @@ export class ImportView extends Component { {initialized === true && importing === false && ( @@ -690,7 +696,7 @@ function isIndexNameValid(name) { ) { return ( ); @@ -707,7 +713,7 @@ function isIndexPatternNameValid(name, indexPatternNames, index) { if (indexPatternNames.find((i) => i === name)) { return ( ); @@ -723,7 +729,7 @@ function isIndexPatternNameValid(name, indexPatternNames, index) { // name should match index return ( ); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/index.js b/x-pack/plugins/file_data_visualizer/public/application/components/import_view/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/index.js rename to x-pack/plugins/file_data_visualizer/public/application/components/import_view/index.js diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/json_editor/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/json_editor/index.ts new file mode 100644 index 0000000000000..641587e5ac732 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/json_editor/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EDITOR_MODE, JsonEditor } from './json_editor'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/json_editor/json_editor.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/json_editor/json_editor.tsx new file mode 100644 index 0000000000000..d429f8dada6ec --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/json_editor/json_editor.tsx @@ -0,0 +1,58 @@ +/* + * 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. + */ + +import React, { FC } from 'react'; + +import { EuiCodeEditor, EuiCodeEditorProps } from '@elastic/eui'; +import { expandLiteralStrings, XJsonMode } from '../../shared_imports'; + +export const EDITOR_MODE = { TEXT: 'text', JSON: 'json', XJSON: new XJsonMode() }; + +interface JobEditorProps { + value: string; + height?: string; + width?: string; + mode?: typeof EDITOR_MODE[keyof typeof EDITOR_MODE]; + readOnly?: boolean; + syntaxChecking?: boolean; + theme?: string; + onChange?: EuiCodeEditorProps['onChange']; +} +export const JsonEditor: FC = ({ + value, + height = '500px', + width = '100%', + mode = EDITOR_MODE.JSON, + readOnly = false, + syntaxChecking = true, + theme = 'textmate', + onChange = () => {}, +}) => { + if (mode === EDITOR_MODE.XJSON) { + value = expandLiteralStrings(value); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/multi_select_picker/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/multi_select_picker/index.ts new file mode 100644 index 0000000000000..9d32228e1c4bc --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/multi_select_picker/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MultiSelectPicker, Option } from './multi_select_picker'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/multi_select_picker/multi_select_picker.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/multi_select_picker/multi_select_picker.tsx new file mode 100644 index 0000000000000..2093b61a7ef4d --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/multi_select_picker/multi_select_picker.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFieldSearch, + EuiFilterButton, + EuiFilterGroup, + EuiFilterSelectItem, + EuiIcon, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, +} from '@elastic/eui'; +import React, { FC, ReactNode, useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export interface Option { + name?: string | ReactNode; + value: string; + checked?: 'on' | 'off'; +} + +const NoFilterItems = () => { + return ( +
+
+ + +

+ +

+
+
+ ); +}; + +export const MultiSelectPicker: FC<{ + options: Option[]; + onChange?: (items: string[]) => void; + title?: string; + checkedOptions: string[]; + dataTestSubj: string; +}> = ({ options, onChange, title, checkedOptions, dataTestSubj }) => { + const [items, setItems] = useState(options); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + if (searchTerm === '') { + setItems(options); + } else { + const filteredOptions = options.filter((o) => o?.value?.includes(searchTerm)); + setItems(filteredOptions); + } + }, [options, searchTerm]); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setIsPopoverOpen(false); + }; + + const handleOnChange = (index: number) => { + if (!items[index] || !Array.isArray(checkedOptions) || onChange === undefined) { + return; + } + const item = items[index]; + const foundIndex = checkedOptions.findIndex((fieldValue) => fieldValue === item.value); + if (foundIndex > -1) { + onChange(checkedOptions.filter((_, idx) => idx !== foundIndex)); + } else { + onChange([...checkedOptions, item.value]); + } + }; + + const button = ( + 0} + numActiveFilters={checkedOptions && checkedOptions.length} + > + {title} + + ); + + return ( + + + + setSearchTerm(e.target.value)} + data-test-subj={`${dataTestSubj}-searchInput`} + /> + +
+ {Array.isArray(items) && items.length > 0 ? ( + items.map((item, index) => { + const checked = + checkedOptions && + checkedOptions.findIndex((fieldValue) => fieldValue === item.value) > -1; + + return ( + handleOnChange(index)} + style={{ flexDirection: 'row' }} + data-test-subj={`${dataTestSubj}-option-${item.value}${ + checked ? '-checked' : '' + }`} + > + {item.name ?? item.value} + + ); + }) + ) : ( + + )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/results_links/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/results_links/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/results_links/results_links.tsx similarity index 61% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/results_links/results_links.tsx index 90b8fb4ac0cbb..03dc06d836bbc 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/results_links/results_links.tsx @@ -9,20 +9,14 @@ import React, { FC, useState, useEffect } from 'react'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiCard, EuiIcon } from '@elastic/eui'; -import { ml } from '../../../../services/ml_api_service'; -import { isFullLicense } from '../../../../license'; -import { checkPermission } from '../../../../capabilities/check_capabilities'; -import { mlNodesAvailable } from '../../../../ml_nodes_check/check_ml_nodes'; -import { useMlKibana, useMlUrlGenerator } from '../../../../contexts/kibana'; -import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; -import { MlCommonGlobalState } from '../../../../../../common/types/ml_url_generator'; import { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState, -} from '../../../../../../../../../src/plugins/discover/public'; -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; - -const RECHECK_DELAY_MS = 3000; +} from '../../../../../../../src/plugins/discover/public'; +import { TimeRange, RefreshInterval } from '../../../../../../../src/plugins/data/public'; +import { FindFileStructureResponse } from '../../../../../file_upload/common'; +import type { FileUploadPluginStart } from '../../../../../file_upload/public'; +import { useFileDataVisualizerKibana } from '../../kibana_context'; interface Props { fieldStats: FindFileStructureResponse['field_stats']; @@ -33,6 +27,13 @@ interface Props { showFilebeatFlyout(): void; } +interface GlobalState { + time?: TimeRange; + refreshInterval?: RefreshInterval; +} + +const RECHECK_DELAY_MS = 3000; + export const ResultsLinks: FC = ({ fieldStats, index, @@ -41,20 +42,19 @@ export const ResultsLinks: FC = ({ createIndexPattern, showFilebeatFlyout, }) => { + const { + services: { fileUpload }, + } = useFileDataVisualizerKibana(); + const [duration, setDuration] = useState({ from: 'now-30m', to: 'now', }); - const [showCreateJobLink, setShowCreateJobLink] = useState(false); - const [globalState, setGlobalState] = useState(); + const [globalState, setGlobalState] = useState(); const [discoverLink, setDiscoverLink] = useState(''); const [indexManagementLink, setIndexManagementLink] = useState(''); const [indexPatternManagementLink, setIndexPatternManagementLink] = useState(''); - const [dataVisualizerLink, setDataVisualizerLink] = useState(''); - const [createJobsSelectTypePage, setCreateJobsSelectTypePage] = useState(''); - - const mlUrlGenerator = useMlUrlGenerator(); const { services: { @@ -63,7 +63,7 @@ export const ResultsLinks: FC = ({ urlGenerators: { getUrlGenerator }, }, }, - } = useMlKibana(); + } = useFileDataVisualizerKibana(); useEffect(() => { let unmounted = false; @@ -98,34 +98,7 @@ export const ResultsLinks: FC = ({ } }; - const getDataVisualizerLink = async (): Promise => { - const _dataVisualizerLink = await mlUrlGenerator.createUrl({ - page: ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, - pageState: { - index: indexPatternId, - globalState, - }, - }); - if (!unmounted) { - setDataVisualizerLink(_dataVisualizerLink); - } - }; - const getADCreateJobsSelectTypePage = async (): Promise => { - const _createJobsSelectTypePage = await mlUrlGenerator.createUrl({ - page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE, - pageState: { - index: indexPatternId, - globalState, - }, - }); - if (!unmounted) { - setCreateJobsSelectTypePage(_createJobsSelectTypePage); - } - }; - getDiscoverUrl(); - getDataVisualizerLink(); - getADCreateJobsSelectTypePage(); if (!unmounted) { setIndexManagementLink( @@ -141,15 +114,16 @@ export const ResultsLinks: FC = ({ return () => { unmounted = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [indexPatternId, getUrlGenerator, JSON.stringify(globalState)]); useEffect(() => { - setShowCreateJobLink(checkPermission('canCreateJob') && mlNodesAvailable()); updateTimeValues(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - const _globalState: MlCommonGlobalState = { + const _globalState: GlobalState = { time: { from: duration.from, to: duration.to, @@ -176,7 +150,7 @@ export const ResultsLinks: FC = ({ async function updateTimeValues(recheck = true) { if (timeFieldName !== undefined) { - const { from, to } = await getFullTimeRange(index, timeFieldName); + const { from, to } = await getFullTimeRange(index, timeFieldName, fileUpload); setDuration({ from: from === null ? duration.from : from, to: to === null ? duration.to : to, @@ -202,7 +176,7 @@ export const ResultsLinks: FC = ({ icon={} title={ } @@ -212,49 +186,13 @@ export const ResultsLinks: FC = ({ )} - {isFullLicense() === true && - timeFieldName !== undefined && - showCreateJobLink && - createIndexPattern && - createJobsSelectTypePage && ( - - } - title={ - - } - description="" - href={createJobsSelectTypePage} - /> - - )} - - {createIndexPattern && dataVisualizerLink && ( - - } - title={ - - } - description="" - href={dataVisualizerLink} - /> - - )} - {indexManagementLink && ( } title={ } @@ -270,7 +208,7 @@ export const ResultsLinks: FC = ({ icon={} title={ } @@ -284,7 +222,7 @@ export const ResultsLinks: FC = ({ icon={} title={ } @@ -296,13 +234,13 @@ export const ResultsLinks: FC = ({ ); }; -async function getFullTimeRange(index: string, timeFieldName: string) { +async function getFullTimeRange( + index: string, + timeFieldName: string, + { getTimeFieldRange }: FileUploadPluginStart +) { const query = { bool: { must: [{ query_string: { analyze_wildcard: true, query: '*' } }] } }; - const resp = await ml.getTimeFieldRange({ - index, - timeFieldName, - query, - }); + const resp = await getTimeFieldRange(index, query, timeFieldName); return { from: moment(resp.start.epoch).toISOString(), diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/results_view/_index.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/_index.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/results_view/_index.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/_results_view.scss b/x-pack/plugins/file_data_visualizer/public/application/components/results_view/_results_view.scss similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/_results_view.scss rename to x-pack/plugins/file_data_visualizer/public/application/components/results_view/_results_view.scss diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/results_view/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/results_view/index.ts diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/results_view.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/results_view/results_view.tsx similarity index 89% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/results_view.tsx rename to x-pack/plugins/file_data_visualizer/public/application/components/results_view/results_view.tsx index 7431bfd4295e4..e2d21f242e4ef 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_view/results_view.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/results_view/results_view.tsx @@ -20,7 +20,7 @@ import { EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; -import { FindFileStructureResponse } from '../../../../../../../file_upload/common'; +import { FindFileStructureResponse } from '../../../../../file_upload/common'; import { FileContents } from '../file_contents'; import { AnalysisSummary } from '../analysis_summary'; @@ -72,7 +72,7 @@ export const ResultsView: FC = ({ showEditFlyout()} disabled={disableButtons}> @@ -80,7 +80,7 @@ export const ResultsView: FC = ({ showExplanationFlyout()} disabled={disableButtons}> @@ -94,7 +94,7 @@ export const ResultsView: FC = ({

diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/_field_data_row.scss b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/_field_data_row.scss new file mode 100644 index 0000000000000..944c31da8cab7 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/_field_data_row.scss @@ -0,0 +1,86 @@ +.fieldDataCard { + height: 420px; + box-shadow: none; + border-color: $euiBorderColor; + + // Note the names of these styles need to match the type of the field they are displaying. + .boolean { + color: $euiColorVis5; + border-color: $euiColorVis5; + } + + .date { + color: $euiColorVis7; + border-color: $euiColorVis7; + } + + .document_count { + color: $euiColorVis2; + border-color: $euiColorVis2; + } + + .geo_point { + color: $euiColorVis8; + border-color: $euiColorVis8; + } + + .ip { + color: $euiColorVis3; + border-color: $euiColorVis3; + } + + .keyword { + color: $euiColorVis0; + border-color: $euiColorVis0; + } + + .number { + color: $euiColorVis1; + border-color: $euiColorVis1; + } + + .text { + color: $euiColorVis9; + border-color: $euiColorVis9; + } + + .type-other, + .unknown { + color: $euiColorVis6; + border-color: $euiColorVis6; + } + + .fieldDataCard__content { + @include euiFontSizeS; + height: 385px; + overflow: hidden; + } + + .fieldDataCard__codeContent { + @include euiCodeFont; + } + + .fieldDataCard__geoContent { + z-index: auto; + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + position: relative; + .embPanel__content { + display: flex; + flex: 1 1 100%; + z-index: 1; + min-height: 0; // Absolute must for Firefox to scroll contents + } + } + + .fieldDataCard__stats { + padding: $euiSizeS $euiSizeS 0 $euiSizeS; + text-align: center; + } + + .fieldDataCard__valuesTitle { + text-transform: uppercase; + } +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/_index.scss new file mode 100644 index 0000000000000..d317d324bae90 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/_index.scss @@ -0,0 +1,56 @@ +@import 'components/field_data_expanded_row/index'; +@import 'components/field_count_stats/index'; +@import 'components/field_data_row/index'; + +.dataVisualizerFieldExpandedRow { + padding-left: $euiSize * 4; + width: 100%; + + .fieldDataCard__valuesTitle { + text-transform: uppercase; + text-align: left; + color: $euiColorDarkShade; + font-weight: bold; + padding-bottom: $euiSizeS; + } + + .fieldDataCard__codeContent { + @include euiCodeFont; + } +} + +.dataVisualizer { + .euiTableRow > .euiTableRowCell { + border-bottom: 0; + border-top: $euiBorderThin; + + } + .euiTableRow-isExpandedRow { + + .euiTableRowCell { + background-color: $euiColorEmptyShade !important; + border-top: 0; + border-bottom: $euiBorderThin; + &:hover { + background-color: $euiColorEmptyShade !important; + } + } + } + .dataVisualizerSummaryTable { + max-width: 350px; + min-width: 250px; + .euiTableRow > .euiTableRowCell { + border-bottom: 0; + } + .euiTableHeaderCell { + display: none; + } + } + .dataVisualizerSummaryTableWrapper { + max-width: 300px; + } + .dataVisualizerMapWrapper { + min-height: 300px; + min-width: 600px; + } +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/expanded_row_field_header/expanded_row_field_header.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/expanded_row_field_header/expanded_row_field_header.tsx new file mode 100644 index 0000000000000..7279bceb8be93 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/expanded_row_field_header/expanded_row_field_header.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiText } from '@elastic/eui'; +import React from 'react'; + +export const ExpandedRowFieldHeader = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/expanded_row_field_header/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/expanded_row_field_header/index.ts new file mode 100644 index 0000000000000..a92fa7f1e0659 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/expanded_row_field_header/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExpandedRowFieldHeader } from './expanded_row_field_header'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/_index.scss new file mode 100644 index 0000000000000..e44082c90ba32 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/_index.scss @@ -0,0 +1,3 @@ +.dataVisualizerFieldCountContainer { + max-width: 300px; +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/index.ts new file mode 100644 index 0000000000000..d841ee2959f62 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { TotalFieldsCount, TotalFieldsCountProps, TotalFieldsStats } from './total_fields_count'; +export { + MetricFieldsCount, + MetricFieldsCountProps, + MetricFieldsStats, +} from './metric_fields_count'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/metric_fields_count.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/metric_fields_count.tsx new file mode 100644 index 0000000000000..93582a7cef9ed --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/metric_fields_count.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiNotificationBadge, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; + +export interface MetricFieldsStats { + visibleMetricsCount: number; + totalMetricFieldsCount: number; +} +export interface MetricFieldsCountProps { + metricsStats?: MetricFieldsStats; +} + +export const MetricFieldsCount: FC = ({ metricsStats }) => { + if ( + !metricsStats || + metricsStats.visibleMetricsCount === undefined || + metricsStats.totalMetricFieldsCount === undefined + ) + return null; + return ( + <> + {metricsStats && ( + + + +
+ +
+
+
+ + + {metricsStats.visibleMetricsCount} + + + + + + + +
+ )} + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/total_fields_count.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/total_fields_count.tsx new file mode 100644 index 0000000000000..9d554c7025d80 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_count_stats/total_fields_count.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiNotificationBadge, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; + +export interface TotalFieldsStats { + visibleFieldsCount: number; + totalFieldsCount: number; +} + +export interface TotalFieldsCountProps { + fieldsCountStats?: TotalFieldsStats; +} + +export const TotalFieldsCount: FC = ({ fieldsCountStats }) => { + if ( + !fieldsCountStats || + fieldsCountStats.visibleFieldsCount === undefined || + fieldsCountStats.totalFieldsCount === undefined + ) + return null; + + return ( + + + +
+ +
+
+
+ + + + {fieldsCountStats.visibleFieldsCount} + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/_index.scss new file mode 100644 index 0000000000000..b878bf0dcc0f6 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/_index.scss @@ -0,0 +1,7 @@ +@import 'number_content'; + +.dataVisualizerExpandedRow { + @include euiBreakpoint('xs', 's', 'm') { + flex-direction: column; + } +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/_number_content.scss b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/_number_content.scss new file mode 100644 index 0000000000000..1f52b0763cdd3 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/_number_content.scss @@ -0,0 +1,4 @@ +.metricDistributionChartContainer { + padding-top: $euiSizeXS; + width: 100%; +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/boolean_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/boolean_content.tsx new file mode 100644 index 0000000000000..7c9ddcdab29c8 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/boolean_content.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, ReactNode, useMemo } from 'react'; +import { EuiBasicTable, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { Axis, BarSeries, Chart, Settings } from '@elastic/charts'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import type { FieldDataRowProps } from '../../types/field_data_row'; +import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; +import { getTFPercentage } from '../../utils'; +import { roundToDecimalPlace } from '../../../utils'; +import { useDataVizChartTheme } from '../../hooks'; +import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; + +function getPercentLabel(value: number): string { + if (value === 0) { + return '0%'; + } + if (value >= 0.1) { + return `${roundToDecimalPlace(value)}%`; + } else { + return '< 0.1%'; + } +} + +function getFormattedValue(value: number, totalCount: number): string { + const percentage = (value / totalCount) * 100; + return `${value} (${getPercentLabel(percentage)})`; +} + +const BOOLEAN_DISTRIBUTION_CHART_HEIGHT = 100; + +export const BooleanContent: FC = ({ config }) => { + const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; + const formattedPercentages = useMemo(() => getTFPercentage(config), [config]); + const theme = useDataVizChartTheme(); + if (!formattedPercentages) return null; + + const { trueCount, falseCount, count } = formattedPercentages; + const summaryTableItems = [ + { + function: 'true', + display: ( + + ), + value: getFormattedValue(trueCount, count), + }, + { + function: 'false', + display: ( + + ), + value: getFormattedValue(falseCount, count), + }, + ]; + const summaryTableColumns = [ + { + name: '', + render: (summaryItem: { display: ReactNode }) => summaryItem.display, + width: '75px', + }, + { + field: 'value', + name: '', + render: (v: string) => {v}, + }, + ]; + + const summaryTableTitle = i18n.translate( + 'xpack.fileDataVisualizer.fieldDataCardExpandedRow.booleanContent.summaryTableTitle', + { + defaultMessage: 'Summary', + } + ); + + return ( + + + + + {summaryTableTitle} + + + + + + + + + + + getFormattedValue(d, count)} + /> + + + + + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/date_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/date_content.tsx new file mode 100644 index 0000000000000..cf34417ad9bbd --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/date_content.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, ReactNode } from 'react'; +import { EuiBasicTable, EuiFlexItem } from '@elastic/eui'; +// @ts-ignore +import { formatDate } from '@elastic/eui/lib/services/format'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { i18n } from '@kbn/i18n'; +import type { FieldDataRowProps } from '../../types/field_data_row'; +import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; +import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; +const TIME_FORMAT = 'MMM D YYYY, HH:mm:ss.SSS'; +interface SummaryTableItem { + function: string; + display: ReactNode; + value: number | string | undefined | null; +} + +export const DateContent: FC = ({ config }) => { + const { stats } = config; + if (stats === undefined) return null; + + const { earliest, latest } = stats; + + const summaryTableTitle = i18n.translate( + 'xpack.fileDataVisualizer.fieldDataCard.cardDate.summaryTableTitle', + { + defaultMessage: 'Summary', + } + ); + const summaryTableItems = [ + { + function: 'earliest', + display: ( + + ), + value: typeof earliest === 'string' ? earliest : formatDate(earliest, TIME_FORMAT), + }, + { + function: 'latest', + display: ( + + ), + value: typeof latest === 'string' ? latest : formatDate(latest, TIME_FORMAT), + }, + ]; + const summaryTableColumns = [ + { + name: '', + render: (summaryItem: { display: ReactNode }) => summaryItem.display, + width: '75px', + }, + { + field: 'value', + name: '', + render: (v: string) => {v}, + }, + ]; + + return ( + + + + {summaryTableTitle} + + className={'dataVisualizerSummaryTable'} + data-test-subj={'mlDateSummaryTable'} + compressed + items={summaryTableItems} + columns={summaryTableColumns} + tableCaption={summaryTableTitle} + tableLayout="auto" + /> + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/document_stats.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/document_stats.tsx new file mode 100644 index 0000000000000..f3ac0d94aa255 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/document_stats.tsx @@ -0,0 +1,93 @@ +/* + * 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. + */ + +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC, ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiFlexItem } from '@elastic/eui'; +import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; +import { FieldDataRowProps } from '../../types'; +import { roundToDecimalPlace } from '../../../utils'; + +const metaTableColumns = [ + { + name: '', + render: (metaItem: { display: ReactNode }) => metaItem.display, + width: '75px', + }, + { + field: 'value', + name: '', + render: (v: string) => {v}, + }, +]; + +const metaTableTitle = i18n.translate( + 'xpack.fileDataVisualizer.fieldDataCardExpandedRow.documentStatsTable.metaTableTitle', + { + defaultMessage: 'Documents stats', + } +); + +export const DocumentStatsTable: FC = ({ config }) => { + if ( + config?.stats === undefined || + config.stats.cardinality === undefined || + config.stats.count === undefined || + config.stats.sampleCount === undefined + ) + return null; + const { cardinality, count, sampleCount } = config.stats; + const metaTableItems = [ + { + function: 'count', + display: ( + + ), + value: count, + }, + { + function: 'percentage', + display: ( + + ), + value: `${roundToDecimalPlace((count / sampleCount) * 100)}%`, + }, + { + function: 'distinctValues', + display: ( + + ), + value: cardinality, + }, + ]; + + return ( + + {metaTableTitle} + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/expanded_row_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/expanded_row_content.tsx new file mode 100644 index 0000000000000..a9f5dc6eaab1d --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/expanded_row_content.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, ReactNode } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; + +interface Props { + children: ReactNode; + dataTestSubj: string; +} +export const ExpandedRowContent: FC = ({ children, dataTestSubj }) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/index.ts new file mode 100644 index 0000000000000..c8db31146936d --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { BooleanContent } from './boolean_content'; +export { DateContent } from './date_content'; +export { GeoPointContent } from '../../../expanded_row/geo_point_content/geo_point_content'; +export { KeywordContent } from './keyword_content'; +export { IpContent } from './ip_content'; +export { NumberContent } from './number_content'; +export { OtherContent } from './other_content'; +export { TextContent } from './text_content'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/ip_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/ip_content.tsx new file mode 100644 index 0000000000000..07adf3103b78e --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/ip_content.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import type { FieldDataRowProps } from '../../types/field_data_row'; +import { TopValues } from '../../../top_values'; +import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; + +export const IpContent: FC = ({ config }) => { + const { stats } = config; + if (stats === undefined) return null; + const { count, sampleCount, cardinality } = stats; + if (count === undefined || sampleCount === undefined || cardinality === undefined) return null; + const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/keyword_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/keyword_content.tsx new file mode 100644 index 0000000000000..3f1a7aad5463f --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/keyword_content.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import type { FieldDataRowProps } from '../../types/field_data_row'; +import { TopValues } from '../../../top_values'; +import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; + +export const KeywordContent: FC = ({ config }) => { + const { stats } = config; + const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; + + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/number_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/number_content.tsx new file mode 100644 index 0000000000000..e83eecb64d02e --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/number_content.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, ReactNode, useEffect, useState } from 'react'; +import { EuiBasicTable, EuiFlexItem, EuiText } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import type { FieldDataRowProps } from '../../types/field_data_row'; +import { kibanaFieldFormat, numberAsOrdinal } from '../../../utils'; +import { + MetricDistributionChart, + MetricDistributionChartData, + buildChartDataFromStats, +} from '../metric_distribution_chart'; +import { TopValues } from '../../../top_values'; +import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; +import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; + +const METRIC_DISTRIBUTION_CHART_WIDTH = 325; +const METRIC_DISTRIBUTION_CHART_HEIGHT = 200; + +interface SummaryTableItem { + function: string; + display: ReactNode; + value: number | string | undefined | null; +} + +export const NumberContent: FC = ({ config }) => { + const { stats } = config; + + useEffect(() => { + const chartData = buildChartDataFromStats(stats, METRIC_DISTRIBUTION_CHART_WIDTH); + setDistributionChartData(chartData); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const defaultChartData: MetricDistributionChartData[] = []; + const [distributionChartData, setDistributionChartData] = useState(defaultChartData); + + if (stats === undefined) return null; + const { min, median, max, distribution } = stats; + const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; + + const summaryTableItems = [ + { + function: 'min', + display: ( + + ), + value: kibanaFieldFormat(min, fieldFormat), + }, + { + function: 'median', + display: ( + + ), + value: kibanaFieldFormat(median, fieldFormat), + }, + { + function: 'max', + display: ( + + ), + value: kibanaFieldFormat(max, fieldFormat), + }, + ]; + const summaryTableColumns = [ + { + name: '', + render: (summaryItem: { display: ReactNode }) => summaryItem.display, + width: '75px', + }, + { + field: 'value', + name: '', + render: (v: string) => {v}, + }, + ]; + + const summaryTableTitle = i18n.translate( + 'xpack.fileDataVisualizer.fieldDataCardExpandedRow.numberContent.summaryTableTitle', + { + defaultMessage: 'Summary', + } + ); + return ( + + + + {summaryTableTitle} + + className={'dataVisualizerSummaryTable'} + compressed + items={summaryTableItems} + columns={summaryTableColumns} + tableCaption={summaryTableTitle} + data-test-subj={'mlNumberSummaryTable'} + /> + + + {stats && ( + + )} + {distribution && ( + + + + + + + + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/other_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/other_content.tsx new file mode 100644 index 0000000000000..cb1605331551e --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/other_content.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import type { FieldDataRowProps } from '../../types/field_data_row'; +import { ExamplesList } from '../../../examples_list'; +import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; + +export const OtherContent: FC = ({ config }) => { + const { stats } = config; + if (stats === undefined) return null; + return ( + + + {Array.isArray(stats.examples) && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/text_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/text_content.tsx new file mode 100644 index 0000000000000..b399f952b4d9d --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/text_content.tsx @@ -0,0 +1,69 @@ +/* + * 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. + */ + +import React, { FC, Fragment } from 'react'; +import { EuiCallOut, EuiFlexItem, EuiSpacer } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import type { FieldDataRowProps } from '../../types/field_data_row'; +import { ExamplesList } from '../../../examples_list'; +import { ExpandedRowContent } from './expanded_row_content'; + +export const TextContent: FC = ({ config }) => { + const { stats } = config; + if (stats === undefined) return null; + + const { examples } = stats; + if (examples === undefined) return null; + + const numExamples = examples.length; + + return ( + + + {numExamples > 0 && } + {numExamples === 0 && ( + + + + _source, + }} + /> + + + + copy_to, + sourceParam: _source, + includesParam: includes, + excludesParam: excludes, + }} + /> + + + )} + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/_index.scss new file mode 100644 index 0000000000000..3afa182560e1e --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/_index.scss @@ -0,0 +1,3 @@ +.dataVisualizerColumnHeaderIcon { + max-width: $euiSizeM; +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/boolean_content_preview.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/boolean_content_preview.tsx new file mode 100644 index 0000000000000..c6c28da0baf04 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/boolean_content_preview.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useMemo } from 'react'; +// import { EuiDataGridColumn } from '@elastic/eui'; +import { OrdinalChartData } from './field_histograms'; +// import { ColumnChart } from '../../../../../../components/data_grid/column_chart'; // TODO copy component +import { FieldDataRowProps } from '../../types'; +import { getTFPercentage } from '../../utils'; + +export const BooleanContentPreview: FC = ({ config }) => { + const chartData = useMemo(() => { + const results = getTFPercentage(config); + if (results) { + const data = [ + { key: 'true', key_as_string: 'true', doc_count: results.trueCount }, + { key: 'false', key_as_string: 'false', doc_count: results.falseCount }, + ]; + return { id: config.fieldName, cardinality: 2, data, type: 'boolean' } as OrdinalChartData; + } + }, [config]); + if (!chartData || config.fieldName === undefined) return null; + + // const columnType: EuiDataGridColumn = { + // id: config.fieldName, + // schema: undefined, + // }; + // const dataTestSubj = `mlDataGridChart-${config.fieldName}`; + + return ( + <> + // + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/column_chart.scss b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/column_chart.scss new file mode 100644 index 0000000000000..63603ee9bd2ec --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/column_chart.scss @@ -0,0 +1,32 @@ +.dataGridChart__histogram { + width: 100%; + height: $euiSizeXL + $euiSizeXXL; +} + +.dataGridChart__legend { + @include euiTextTruncate; + @include euiFontSizeXS; + + color: $euiColorMediumShade; + display: block; + overflow-x: hidden; + margin: $euiSizeXS 0 0 0; + font-style: italic; + font-weight: normal; + text-align: left; +} + +.dataGridChart__legend--numeric { + text-align: right; +} + +.dataGridChart__legendBoolean { + width: 100%; + min-width: $euiButtonMinWidth; + td { text-align: center } +} + +/* Override to align column header to bottom of cell when no chart is available */ +.dataGrid .euiDataGridHeaderCell__content { + margin-top: auto; +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/column_chart.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/column_chart.tsx new file mode 100644 index 0000000000000..ed4b82005db29 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/column_chart.tsx @@ -0,0 +1,84 @@ +/* + * 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. + */ + +import React, { FC } from 'react'; +import classNames from 'classnames'; + +import { BarSeries, Chart, Settings } from '@elastic/charts'; +import { EuiDataGridColumn } from '@elastic/eui'; + +import './column_chart.scss'; + +import { isUnsupportedChartData, ChartData } from './field_histograms'; + +import { useColumnChart } from './use_column_chart'; + +interface Props { + chartData: ChartData; + columnType: EuiDataGridColumn; + dataTestSubj: string; + hideLabel?: boolean; + maxChartColumns?: number; +} + +const columnChartTheme = { + background: { color: 'transparent' }, + chartMargins: { + left: 0, + right: 0, + top: 0, + bottom: 1, + }, + chartPaddings: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + scales: { barsPadding: 0.1 }, +}; +export const ColumnChart: FC = ({ + chartData, + columnType, + dataTestSubj, + hideLabel, + maxChartColumns, +}) => { + const { data, legendText, xScaleType } = useColumnChart(chartData, columnType, maxChartColumns); + + return ( +
+ {!isUnsupportedChartData(chartData) && data.length > 0 && ( +
+ + + d.datum.color} + data={data} + /> + +
+ )} +
+ {legendText} +
+ {!hideLabel &&
{columnType.id}
} +
+ ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/distinct_values.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/distinct_values.tsx new file mode 100644 index 0000000000000..92e0d1a16229f --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/distinct_values.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; + +import React from 'react'; + +export const DistinctValues = ({ cardinality }: { cardinality?: number }) => { + if (cardinality === undefined) return null; + return ( + + + + + + {cardinality} + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/document_stats.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/document_stats.tsx new file mode 100644 index 0000000000000..7d0bda6ac47ea --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/document_stats.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; + +import React from 'react'; +import type { FieldDataRowProps } from '../../types/field_data_row'; +import { roundToDecimalPlace } from '../../../utils'; + +export const DocumentStat = ({ config }: FieldDataRowProps) => { + const { stats } = config; + if (stats === undefined) return null; + + const { count, sampleCount } = stats; + if (count === undefined || sampleCount === undefined) return null; + + const docsPercent = roundToDecimalPlace((count / sampleCount) * 100); + + return ( + + + + + + {count} ({docsPercent}%) + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/field_histograms.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/field_histograms.ts new file mode 100644 index 0000000000000..22b0195a579ac --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/field_histograms.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface NumericDataItem { + key: number; + key_as_string?: string | number; + doc_count: number; +} + +export interface NumericChartData { + data: NumericDataItem[]; + id: string; + interval: number; + stats: [number, number]; + type: 'numeric'; +} + +export const isNumericChartData = (arg: any): arg is NumericChartData => { + return ( + typeof arg === 'object' && + arg.hasOwnProperty('data') && + arg.hasOwnProperty('id') && + arg.hasOwnProperty('interval') && + arg.hasOwnProperty('stats') && + arg.hasOwnProperty('type') && + arg.type === 'numeric' + ); +}; + +export interface OrdinalDataItem { + key: string; + key_as_string?: string; + doc_count: number; +} + +export interface OrdinalChartData { + cardinality: number; + data: OrdinalDataItem[]; + id: string; + type: 'ordinal' | 'boolean'; +} + +export const isOrdinalChartData = (arg: any): arg is OrdinalChartData => { + return ( + typeof arg === 'object' && + arg.hasOwnProperty('data') && + arg.hasOwnProperty('cardinality') && + arg.hasOwnProperty('id') && + arg.hasOwnProperty('type') && + (arg.type === 'ordinal' || arg.type === 'boolean') + ); +}; + +export interface UnsupportedChartData { + id: string; + type: 'unsupported'; +} + +export const isUnsupportedChartData = (arg: any): arg is UnsupportedChartData => { + return typeof arg === 'object' && arg.hasOwnProperty('type') && arg.type === 'unsupported'; +}; + +export type ChartDataItem = NumericDataItem | OrdinalDataItem; +export type ChartData = NumericChartData | OrdinalChartData | UnsupportedChartData; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/index.ts new file mode 100644 index 0000000000000..e4c0cc80eeb35 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { BooleanContentPreview } from './boolean_content_preview'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/number_content_preview.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/number_content_preview.tsx new file mode 100644 index 0000000000000..00150bdfe8b7a --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/number_content_preview.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import classNames from 'classnames'; +import { + MetricDistributionChart, + MetricDistributionChartData, + buildChartDataFromStats, +} from '../metric_distribution_chart'; +import { FieldVisConfig } from '../../types'; +import { kibanaFieldFormat, formatSingleValue } from '../../../utils'; + +const METRIC_DISTRIBUTION_CHART_WIDTH = 150; +const METRIC_DISTRIBUTION_CHART_HEIGHT = 80; + +export interface NumberContentPreviewProps { + config: FieldVisConfig; +} + +export const IndexBasedNumberContentPreview: FC = ({ config }) => { + const { stats, fieldFormat, fieldName } = config; + const defaultChartData: MetricDistributionChartData[] = []; + const [distributionChartData, setDistributionChartData] = useState(defaultChartData); + const [legendText, setLegendText] = useState<{ min: number; max: number } | undefined>(); + const dataTestSubj = `mlDataGridChart-${fieldName}`; + useEffect(() => { + const chartData = buildChartDataFromStats(stats, METRIC_DISTRIBUTION_CHART_WIDTH); + if ( + Array.isArray(chartData) && + chartData[0].x !== undefined && + chartData[chartData.length - 1].x !== undefined + ) { + setDistributionChartData(chartData); + setLegendText({ + min: formatSingleValue(chartData[0].x), + max: formatSingleValue(chartData[chartData.length - 1].x), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ +
+
+ {legendText && ( + <> + + + + {kibanaFieldFormat(legendText.min, fieldFormat)} + + + {kibanaFieldFormat(legendText.max, fieldFormat)} + + + + )} +
+
+ ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/top_values_preview.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/top_values_preview.tsx new file mode 100644 index 0000000000000..63b15fdf30b3b --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/top_values_preview.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { EuiDataGridColumn } from '@elastic/eui'; +import { ChartData, OrdinalDataItem } from './field_histograms'; +import { ColumnChart } from './column_chart'; +import type { FieldDataRowProps } from '../../types/field_data_row'; + +export const TopValuesPreview: FC = ({ config }) => { + const { stats } = config; + if (stats === undefined) return null; + const { topValues, cardinality } = stats; + if (cardinality === undefined || topValues === undefined || config.fieldName === undefined) + return null; + + const data: OrdinalDataItem[] = topValues.map((d) => ({ + ...d, + key: d.key.toString(), + })); + const chartData: ChartData = { + cardinality, + data, + id: config.fieldName, + type: 'ordinal', + }; + const columnType: EuiDataGridColumn = { + id: config.fieldName, + schema: undefined, + }; + return ( + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/use_column_chart.test.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/use_column_chart.test.tsx new file mode 100644 index 0000000000000..2c92c366b2d73 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/use_column_chart.test.tsx @@ -0,0 +1,177 @@ +/* + * 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. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import '@testing-library/jest-dom/extend-expect'; + +import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/public'; + +import { + isNumericChartData, + isOrdinalChartData, + isUnsupportedChartData, + NumericChartData, + OrdinalChartData, + UnsupportedChartData, +} from './field_histograms'; + +import { getFieldType, getLegendText, getXScaleType, useColumnChart } from './use_column_chart'; + +describe('getFieldType()', () => { + it('should return the Kibana field type for a given EUI data grid schema', () => { + expect(getFieldType('text')).toBe('string'); + expect(getFieldType('datetime')).toBe('date'); + expect(getFieldType('numeric')).toBe('number'); + expect(getFieldType('boolean')).toBe('boolean'); + expect(getFieldType('json')).toBe('object'); + expect(getFieldType('non-aggregatable')).toBe(undefined); + }); +}); + +describe('getXScaleType()', () => { + it('should return the corresponding x axis scale type for a Kibana field type', () => { + expect(getXScaleType(KBN_FIELD_TYPES.BOOLEAN)).toBe('ordinal'); + expect(getXScaleType(KBN_FIELD_TYPES.IP)).toBe('ordinal'); + expect(getXScaleType(KBN_FIELD_TYPES.STRING)).toBe('ordinal'); + expect(getXScaleType(KBN_FIELD_TYPES.DATE)).toBe('time'); + expect(getXScaleType(KBN_FIELD_TYPES.NUMBER)).toBe('linear'); + expect(getXScaleType(undefined)).toBe(undefined); + }); +}); + +const validNumericChartData: NumericChartData = { + data: [], + id: 'the-id', + interval: 10, + stats: [0, 0], + type: 'numeric', +}; + +const validOrdinalChartData: OrdinalChartData = { + cardinality: 10, + data: [], + id: 'the-id', + type: 'ordinal', +}; + +const validUnsupportedChartData: UnsupportedChartData = { id: 'the-id', type: 'unsupported' }; + +describe('isNumericChartData()', () => { + it('should return true for valid numeric chart data', () => { + expect(isNumericChartData(validNumericChartData)).toBe(true); + }); + it('should return false for invalid numeric chart data', () => { + expect(isNumericChartData(undefined)).toBe(false); + expect(isNumericChartData({})).toBe(false); + expect(isNumericChartData({ data: [] })).toBe(false); + expect(isNumericChartData(validOrdinalChartData)).toBe(false); + expect(isNumericChartData(validUnsupportedChartData)).toBe(false); + }); +}); + +describe('isOrdinalChartData()', () => { + it('should return true for valid ordinal chart data', () => { + expect(isOrdinalChartData(validOrdinalChartData)).toBe(true); + }); + it('should return false for invalid ordinal chart data', () => { + expect(isOrdinalChartData(undefined)).toBe(false); + expect(isOrdinalChartData({})).toBe(false); + expect(isOrdinalChartData({ data: [] })).toBe(false); + expect(isOrdinalChartData(validNumericChartData)).toBe(false); + expect(isOrdinalChartData(validUnsupportedChartData)).toBe(false); + }); +}); + +describe('isUnsupportedChartData()', () => { + it('should return true for unsupported chart data', () => { + expect(isUnsupportedChartData(validUnsupportedChartData)).toBe(true); + }); + it('should return false for invalid unsupported chart data', () => { + expect(isUnsupportedChartData(undefined)).toBe(false); + expect(isUnsupportedChartData({})).toBe(false); + expect(isUnsupportedChartData({ data: [] })).toBe(false); + expect(isUnsupportedChartData(validNumericChartData)).toBe(false); + expect(isUnsupportedChartData(validOrdinalChartData)).toBe(false); + }); +}); + +describe('getLegendText()', () => { + it('should return the chart legend text for unsupported chart types', () => { + expect(getLegendText(validUnsupportedChartData)).toBe('Chart not supported.'); + }); + it('should return the chart legend text for empty datasets', () => { + expect(getLegendText(validNumericChartData)).toBe('0 documents contain field.'); + }); + it('should return the chart legend text for boolean chart types', () => { + const { getByText } = render( + <> + {getLegendText({ + cardinality: 2, + data: [ + { key: 'true', key_as_string: 'true', doc_count: 10 }, + { key: 'false', key_as_string: 'false', doc_count: 20 }, + ], + id: 'the-id', + type: 'boolean', + })} + + ); + expect(getByText('true')).toBeInTheDocument(); + expect(getByText('false')).toBeInTheDocument(); + }); + it('should return the chart legend text for ordinal chart data with less than max categories', () => { + expect(getLegendText({ ...validOrdinalChartData, data: [{ key: 'cat', doc_count: 10 }] })).toBe( + '10 categories' + ); + }); + it('should return the chart legend text for ordinal chart data with more than max categories', () => { + expect( + getLegendText({ + ...validOrdinalChartData, + cardinality: 30, + data: [{ key: 'cat', doc_count: 10 }], + }) + ).toBe('top 20 of 30 categories'); + }); + it('should return the chart legend text for numeric datasets', () => { + expect( + getLegendText({ + ...validNumericChartData, + data: [{ key: 1, doc_count: 10 }], + stats: [1, 100], + }) + ).toBe('1 - 100'); + expect( + getLegendText({ + ...validNumericChartData, + data: [{ key: 1, doc_count: 10 }], + stats: [100, 100], + }) + ).toBe('100'); + expect( + getLegendText({ + ...validNumericChartData, + data: [{ key: 1, doc_count: 10 }], + stats: [1.2345, 6.3456], + }) + ).toBe('1.23 - 6.35'); + }); +}); + +describe('useColumnChart()', () => { + it('should return the column chart hook data', () => { + const { result } = renderHook(() => + useColumnChart(validNumericChartData, { id: 'the-id', schema: 'numeric' }) + ); + + expect(result.current.data).toStrictEqual([]); + expect(result.current.legendText).toBe('0 documents contain field.'); + expect(result.current.xScaleType).toBe('linear'); + }); +}); diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/use_column_chart.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/use_column_chart.tsx new file mode 100644 index 0000000000000..bd1df7f32c375 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_row/use_column_chart.tsx @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { BehaviorSubject } from 'rxjs'; +import React from 'react'; + +import useObservable from 'react-use/lib/useObservable'; + +import { euiPaletteColorBlind, EuiDataGridColumn } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/public'; + +import { + isNumericChartData, + isOrdinalChartData, + ChartData, + ChartDataItem, + NumericDataItem, + OrdinalDataItem, +} from './field_histograms'; + +const NON_AGGREGATABLE = 'non-aggregatable'; + +export const hoveredRow$ = new BehaviorSubject(null); + +export const BAR_COLOR = euiPaletteColorBlind()[0]; +const BAR_COLOR_BLUR = euiPaletteColorBlind({ rotations: 2 })[10]; +const MAX_CHART_COLUMNS = 20; + +type XScaleType = 'ordinal' | 'time' | 'linear' | undefined; +export const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => { + switch (kbnFieldType) { + case KBN_FIELD_TYPES.BOOLEAN: + case KBN_FIELD_TYPES.IP: + case KBN_FIELD_TYPES.STRING: + return 'ordinal'; + case KBN_FIELD_TYPES.DATE: + return 'time'; + case KBN_FIELD_TYPES.NUMBER: + return 'linear'; + } +}; + +export const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => { + if (schema === NON_AGGREGATABLE) { + return undefined; + } + + let fieldType: KBN_FIELD_TYPES; + + switch (schema) { + case 'datetime': + fieldType = KBN_FIELD_TYPES.DATE; + break; + case 'numeric': + fieldType = KBN_FIELD_TYPES.NUMBER; + break; + case 'boolean': + fieldType = KBN_FIELD_TYPES.BOOLEAN; + break; + case 'json': + fieldType = KBN_FIELD_TYPES.OBJECT; + break; + default: + fieldType = KBN_FIELD_TYPES.STRING; + } + + return fieldType; +}; + +type LegendText = string | JSX.Element; +export const getLegendText = ( + chartData: ChartData, + maxChartColumns = MAX_CHART_COLUMNS +): LegendText => { + if (chartData.type === 'unsupported') { + return i18n.translate('xpack.fileDataVisualizer.dataGridChart.histogramNotAvailable', { + defaultMessage: 'Chart not supported.', + }); + } + + if (chartData.data.length === 0) { + return i18n.translate('xpack.fileDataVisualizer.dataGridChart.notEnoughData', { + defaultMessage: `0 documents contain field.`, + }); + } + + if (chartData.type === 'boolean') { + return ( + + + + {chartData.data[0] !== undefined && } + {chartData.data[1] !== undefined && } + + +
{chartData.data[0].key_as_string}{chartData.data[1].key_as_string}
+ ); + } + + if (isOrdinalChartData(chartData) && chartData.cardinality <= maxChartColumns) { + return i18n.translate('xpack.fileDataVisualizer.dataGridChart.singleCategoryLegend', { + defaultMessage: `{cardinality, plural, one {# category} other {# categories}}`, + values: { cardinality: chartData.cardinality }, + }); + } + + if (isOrdinalChartData(chartData) && chartData.cardinality > maxChartColumns) { + return i18n.translate('xpack.fileDataVisualizer.dataGridChart.topCategoriesLegend', { + defaultMessage: `top {maxChartColumns} of {cardinality} categories`, + values: { cardinality: chartData.cardinality, maxChartColumns }, + }); + } + + if (isNumericChartData(chartData)) { + const fromValue = Math.round(chartData.stats[0] * 100) / 100; + const toValue = Math.round(chartData.stats[1] * 100) / 100; + + return fromValue !== toValue ? `${fromValue} - ${toValue}` : '' + fromValue; + } + + return ''; +}; + +interface ColumnChart { + data: ChartDataItem[]; + legendText: LegendText; + xScaleType: XScaleType; +} + +export const useColumnChart = ( + chartData: ChartData, + columnType: EuiDataGridColumn, + maxChartColumns?: number +): ColumnChart => { + const fieldType = getFieldType(columnType.schema); + + const hoveredRow = useObservable(hoveredRow$); + + const xScaleType = getXScaleType(fieldType); + + const getColor = (d: ChartDataItem) => { + if (hoveredRow === undefined || hoveredRow === null) { + return BAR_COLOR; + } + + if ( + isOrdinalChartData(chartData) && + xScaleType === 'ordinal' && + hoveredRow._source[columnType.id] === d.key + ) { + return BAR_COLOR; + } + + if ( + isNumericChartData(chartData) && + xScaleType === 'linear' && + hoveredRow._source[columnType.id] >= +d.key && + hoveredRow._source[columnType.id] < +d.key + chartData.interval + ) { + return BAR_COLOR; + } + + if ( + isNumericChartData(chartData) && + xScaleType === 'time' && + moment(hoveredRow._source[columnType.id]).unix() * 1000 >= +d.key && + moment(hoveredRow._source[columnType.id]).unix() * 1000 < +d.key + chartData.interval + ) { + return BAR_COLOR; + } + + return BAR_COLOR_BLUR; + }; + + let data: ChartDataItem[] = []; + + // The if/else if/else is a work-around because `.map()` doesn't work with union types. + // See TS Caveats for details: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats + if (isOrdinalChartData(chartData)) { + data = chartData.data.map((d: OrdinalDataItem) => ({ + ...d, + key_as_string: d.key_as_string ?? d.key, + color: getColor(d), + })); + } else if (isNumericChartData(chartData)) { + data = chartData.data.map((d: NumericDataItem) => ({ + ...d, + key_as_string: d.key_as_string || d.key, + color: getColor(d), + })); + } + + return { + data, + legendText: getLegendText(chartData, maxChartColumns), + xScaleType, + }; +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/index.ts new file mode 100644 index 0000000000000..72947f2953cb8 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MetricDistributionChart, MetricDistributionChartData } from './metric_distribution_chart'; +export { buildChartDataFromStats } from './metric_distribution_chart_data_builder'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart.tsx new file mode 100644 index 0000000000000..caa560488d499 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + AreaSeries, + Axis, + Chart, + CurveType, + Position, + ScaleType, + Settings, + TooltipValue, + TooltipValueFormatter, +} from '@elastic/charts'; + +import { MetricDistributionChartTooltipHeader } from './metric_distribution_chart_tooltip_header'; +import { kibanaFieldFormat } from '../../../utils'; +import { useDataVizChartTheme } from '../../hooks'; + +interface ChartTooltipValue extends TooltipValue { + skipHeader?: boolean; +} + +export interface MetricDistributionChartData { + x: number; + y: number; + dataMin: number; + dataMax: number; + percent: number; +} + +interface Props { + width: number; + height: number; + chartData: MetricDistributionChartData[]; + fieldFormat?: any; // Kibana formatter for field being viewed + hideXAxis?: boolean; +} + +const SPEC_ID = 'metric_distribution'; + +export const MetricDistributionChart: FC = ({ + width, + height, + chartData, + fieldFormat, + hideXAxis, +}) => { + // This value is shown to label the y axis values in the tooltip. + // Ideally we wouldn't show these values at all in the tooltip, + // but this is not yet possible with Elastic charts. + const seriesName = i18n.translate( + 'xpack.fileDataVisualizer.fieldDataCard.metricDistributionChart.seriesName', + { + defaultMessage: 'distribution', + } + ); + + const theme = useDataVizChartTheme(); + + const headerFormatter: TooltipValueFormatter = (tooltipData: ChartTooltipValue) => { + const xValue = tooltipData.value; + const chartPoint: MetricDistributionChartData | undefined = chartData.find( + (data) => data.x === xValue + ); + + return ( + + ); + }; + + return ( +
+ + + kibanaFieldFormat(d, fieldFormat)} + hide={hideXAxis === true} + /> + d.toFixed(3)} hide={true} /> + 0 ? chartData : [{ x: 0, y: 0 }]} + curve={CurveType.CURVE_STEP_AFTER} + /> + +
+ ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart_data_builder.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart_data_builder.tsx new file mode 100644 index 0000000000000..a65b6bdc7458f --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart_data_builder.tsx @@ -0,0 +1,156 @@ +/* + * 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. + */ + +const METRIC_DISTRIBUTION_CHART_MIN_BAR_WIDTH = 3; // Minimum bar width, in pixels. +const METRIC_DISTRIBUTION_CHART_MAX_BAR_HEIGHT_FACTOR = 20; // Max bar height relative to median bar height. + +import { MetricDistributionChartData } from './metric_distribution_chart'; + +interface DistributionPercentile { + minValue: number; + maxValue: number; + percent: number; +} + +interface DistributionChartBar { + x0: number; + x1: number; + y: number; + dataMin: number; + dataMax: number; + percent: number; + isMinWidth: boolean; +} + +export function buildChartDataFromStats( + stats: any, + chartWidth: number +): MetricDistributionChartData[] { + // Process the raw percentiles data so it is in a suitable format for plotting in the metric distribution chart. + let chartData: MetricDistributionChartData[] = []; + + const distribution = stats.distribution; + if (distribution === undefined) { + return chartData; + } + + const percentiles: DistributionPercentile[] = distribution.percentiles; + if (percentiles.length === 0) { + return chartData; + } + + // Adjust x axis min and max if there is a single bar. + const minX = percentiles[0].minValue; + const maxX = percentiles[percentiles.length - 1].maxValue; + + let xAxisMin: number = minX; + let xAxisMax: number = maxX; + if (maxX === minX) { + if (minX !== 0) { + xAxisMin = 0; + xAxisMax = 2 * minX; + } else { + xAxisMax = 1; + } + } + + // Adjust the right hand x coordinates so that each bar is at least METRIC_DISTRIBUTION_CHART_MIN_BAR_WIDTH. + const minBarWidth = + (METRIC_DISTRIBUTION_CHART_MIN_BAR_WIDTH / chartWidth) * (xAxisMax - xAxisMin); + const processedData: DistributionChartBar[] = []; + let lastBar: DistributionChartBar; + percentiles.forEach((data, index) => { + if (index === 0) { + const bar: DistributionChartBar = { + x0: data.minValue, + x1: Math.max(data.minValue + minBarWidth, data.maxValue), + y: 0, // Set below + dataMin: data.minValue, + dataMax: data.maxValue, + percent: data.percent, + isMinWidth: false, + }; + + // Scale the height of the bar according to the range of data values in the bar. + bar.y = + (data.percent / (bar.x1 - bar.x0)) * + Math.max(1, minBarWidth / Math.max(data.maxValue - data.minValue, 0.5 * minBarWidth)); + bar.isMinWidth = data.maxValue <= data.minValue + minBarWidth; + processedData.push(bar); + lastBar = bar; + } else { + if (lastBar.isMinWidth === false || data.maxValue > lastBar.x1) { + const bar = { + x0: lastBar.x1, + x1: Math.max(lastBar.x1 + minBarWidth, data.maxValue), + y: 0, // Set below + dataMin: data.minValue, + dataMax: data.maxValue, + percent: data.percent, + isMinWidth: false, + }; + + // Scale the height of the bar according to the range of data values in the bar. + bar.y = + (data.percent / (bar.x1 - bar.x0)) * + Math.max(1, minBarWidth / Math.max(data.maxValue - data.minValue, 0.5 * minBarWidth)); + bar.isMinWidth = data.maxValue <= lastBar.x1 + minBarWidth; + processedData.push(bar); + lastBar = bar; + } else { + // Combine bars which are less than minBarWidth apart. + lastBar.percent = lastBar.percent + data.percent; + lastBar.y = lastBar.percent / (lastBar.x1 - lastBar.x0); + lastBar.dataMax = data.maxValue; + } + } + }); + + if (maxX !== minX) { + xAxisMax = processedData[processedData.length - 1].x1; + } + + // Adjust the maximum bar height to be (METRIC_DISTRIBUTION_CHART_MAX_BAR_HEIGHT_FACTOR * median bar height). + let barHeights = processedData.map((data) => data.y); + barHeights = barHeights.sort((a, b) => a - b); + + let maxBarHeight = 0; + const processedDataLength = processedData.length; + if (Math.abs(processedDataLength % 2) === 1) { + maxBarHeight = + METRIC_DISTRIBUTION_CHART_MAX_BAR_HEIGHT_FACTOR * + barHeights[Math.floor(processedDataLength / 2)]; + } else { + maxBarHeight = + (METRIC_DISTRIBUTION_CHART_MAX_BAR_HEIGHT_FACTOR * + (barHeights[Math.floor(processedDataLength / 2) - 1] + + barHeights[Math.floor(processedDataLength / 2)])) / + 2; + } + + processedData.forEach((data) => { + data.y = Math.min(data.y, maxBarHeight); + }); + + // Convert the data to the format used by the chart. + chartData = processedData.map((data) => { + const { x0, y, dataMin, dataMax, percent } = data; + return { x: x0, y, dataMin, dataMax, percent }; + }); + + // Add a final point to drop the curve back to the y axis. + const last = processedData[processedData.length - 1]; + chartData.push({ + x: last.x1, + y: 0, + dataMin: last.dataMin, + dataMax: last.dataMax, + percent: last.percent, + }); + + return chartData; +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart_tooltip_header.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart_tooltip_header.tsx new file mode 100644 index 0000000000000..9fd613ac96b8e --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/metric_distribution_chart/metric_distribution_chart_tooltip_header.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { MetricDistributionChartData } from './metric_distribution_chart'; +import { kibanaFieldFormat } from '../../../utils'; + +interface Props { + chartPoint: MetricDistributionChartData | undefined; + maxWidth: number; + fieldFormat?: any; // Kibana formatter for field being viewed +} + +export const MetricDistributionChartTooltipHeader: FC = ({ + chartPoint, + maxWidth, + fieldFormat, +}) => { + if (chartPoint === undefined) { + return null; + } + + return ( +
+ {chartPoint.dataMax > chartPoint.dataMin ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/data_visualizer_stats_table.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/data_visualizer_stats_table.tsx new file mode 100644 index 0000000000000..bfa40c487a2ac --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/data_visualizer_stats_table.tsx @@ -0,0 +1,284 @@ +/* + * 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. + */ + +import React, { useMemo, useState } from 'react'; + +import { + CENTER_ALIGNMENT, + EuiBasicTableColumn, + EuiButtonIcon, + EuiFlexItem, + EuiIcon, + EuiInMemoryTable, + EuiText, + HorizontalAlignment, + LEFT_ALIGNMENT, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiTableComputedColumnType } from '@elastic/eui/src/components/basic_table/table_types'; +import { JOB_FIELD_TYPES, JobFieldType, DataVisualizerTableState } from '../../../../common'; +import { FieldTypeIcon } from '../field_type_icon'; +import { DocumentStat } from './components/field_data_row/document_stats'; +import { DistinctValues } from './components/field_data_row/distinct_values'; +import { IndexBasedNumberContentPreview } from './components/field_data_row/number_content_preview'; + +import { useTableSettings } from './use_table_settings'; +import { TopValuesPreview } from './components/field_data_row/top_values_preview'; +import { + FieldVisConfig, + FileBasedFieldVisConfig, + isIndexBasedFieldVisConfig, +} from './types/field_vis_config'; +import { FileBasedNumberContentPreview } from '../field_data_row'; +import { BooleanContentPreview } from './components/field_data_row'; + +const FIELD_NAME = 'fieldName'; + +export type ItemIdToExpandedRowMap = Record; + +type DataVisualizerTableItem = FieldVisConfig | FileBasedFieldVisConfig; +interface DataVisualizerTableProps { + items: T[]; + pageState: DataVisualizerTableState; + updatePageState: (update: DataVisualizerTableState) => void; + getItemIdToExpandedRowMap: (itemIds: string[], items: T[]) => ItemIdToExpandedRowMap; + extendedColumns?: Array>; +} + +export const DataVisualizerTable = ({ + items, + pageState, + updatePageState, + getItemIdToExpandedRowMap, + extendedColumns, +}: DataVisualizerTableProps) => { + const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); + const [expandAll, toggleExpandAll] = useState(false); + + const { onTableChange, pagination, sorting } = useTableSettings( + items, + pageState, + updatePageState + ); + const showDistributions: boolean = + ('showDistributions' in pageState && pageState.showDistributions) ?? true; + const toggleShowDistribution = () => { + updatePageState({ + ...pageState, + showDistributions: !showDistributions, + }); + }; + + function toggleDetails(item: DataVisualizerTableItem) { + if (item.fieldName === undefined) return; + const index = expandedRowItemIds.indexOf(item.fieldName); + if (index !== -1) { + expandedRowItemIds.splice(index, 1); + } else { + expandedRowItemIds.push(item.fieldName); + } + + // spread to a new array otherwise the component wouldn't re-render + setExpandedRowItemIds([...expandedRowItemIds]); + } + + const columns = useMemo(() => { + const expanderColumn: EuiTableComputedColumnType = { + name: ( + toggleExpandAll(!expandAll)} + aria-label={ + !expandAll + ? i18n.translate( + 'xpack.fileDataVisualizer.datavisualizer.dataGrid.expandDetailsForAllAriaLabel', + { + defaultMessage: 'Expand details for all fields', + } + ) + : i18n.translate( + 'xpack.fileDataVisualizer.datavisualizer.dataGrid.collapseDetailsForAllAriaLabel', + { + defaultMessage: 'Collapse details for all fields', + } + ) + } + iconType={expandAll ? 'arrowUp' : 'arrowDown'} + /> + ), + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: (item: DataVisualizerTableItem) => { + if (item.fieldName === undefined) return null; + const direction = expandedRowItemIds.includes(item.fieldName) ? 'arrowUp' : 'arrowDown'; + return ( + toggleDetails(item)} + aria-label={ + expandedRowItemIds.includes(item.fieldName) + ? i18n.translate('xpack.fileDataVisualizer.datavisualizer.dataGrid.rowCollapse', { + defaultMessage: 'Hide details for {fieldName}', + values: { fieldName: item.fieldName }, + }) + : i18n.translate('xpack.fileDataVisualizer.datavisualizer.dataGrid.rowExpand', { + defaultMessage: 'Show details for {fieldName}', + values: { fieldName: item.fieldName }, + }) + } + iconType={direction} + /> + ); + }, + 'data-test-subj': 'mlDataVisualizerTableColumnDetailsToggle', + }; + + const baseColumns = [ + expanderColumn, + { + field: 'type', + name: i18n.translate('xpack.fileDataVisualizer.datavisualizer.dataGrid.typeColumnName', { + defaultMessage: 'Type', + }), + render: (fieldType: JobFieldType) => { + return ; + }, + width: '75px', + sortable: true, + align: CENTER_ALIGNMENT as HorizontalAlignment, + 'data-test-subj': 'mlDataVisualizerTableColumnType', + }, + { + field: 'fieldName', + name: i18n.translate('xpack.fileDataVisualizer.datavisualizer.dataGrid.nameColumnName', { + defaultMessage: 'Name', + }), + sortable: true, + truncateText: true, + render: (fieldName: string) => ( + + {fieldName} + + ), + align: LEFT_ALIGNMENT as HorizontalAlignment, + 'data-test-subj': 'mlDataVisualizerTableColumnName', + }, + { + field: 'docCount', + name: i18n.translate( + 'xpack.fileDataVisualizer.datavisualizer.dataGrid.documentsCountColumnName', + { + defaultMessage: 'Documents (%)', + } + ), + render: (value: number | undefined, item: DataVisualizerTableItem) => ( + + ), + sortable: (item: DataVisualizerTableItem) => item?.stats?.count, + align: LEFT_ALIGNMENT as HorizontalAlignment, + 'data-test-subj': 'mlDataVisualizerTableColumnDocumentsCount', + }, + { + field: 'stats.cardinality', + name: i18n.translate( + 'xpack.fileDataVisualizer.datavisualizer.dataGrid.distinctValuesColumnName', + { + defaultMessage: 'Distinct values', + } + ), + render: (cardinality?: number) => , + sortable: true, + align: LEFT_ALIGNMENT as HorizontalAlignment, + 'data-test-subj': 'mlDataVisualizerTableColumnDistinctValues', + }, + { + name: ( +
+ + {i18n.translate( + 'xpack.fileDataVisualizer.datavisualizer.dataGrid.distributionsColumnName', + { + defaultMessage: 'Distributions', + } + )} + toggleShowDistribution()} + aria-label={i18n.translate( + 'xpack.fileDataVisualizer.datavisualizer.dataGrid.showDistributionsAriaLabel', + { + defaultMessage: 'Show distributions', + } + )} + /> +
+ ), + render: (item: DataVisualizerTableItem) => { + if (item === undefined || showDistributions === false) return null; + if ( + (item.type === JOB_FIELD_TYPES.KEYWORD || item.type === JOB_FIELD_TYPES.IP) && + item.stats?.topValues !== undefined + ) { + return ; + } + + if (item.type === JOB_FIELD_TYPES.NUMBER) { + if (isIndexBasedFieldVisConfig(item) && item.stats?.distribution !== undefined) { + return ; + } else { + return ; + } + } + + if (item.type === JOB_FIELD_TYPES.BOOLEAN) { + return ; + } + + return null; + }, + align: LEFT_ALIGNMENT as HorizontalAlignment, + 'data-test-subj': 'mlDataVisualizerTableColumnDistribution', + }, + ]; + return extendedColumns ? [...baseColumns, ...extendedColumns] : baseColumns; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expandAll, showDistributions, updatePageState, extendedColumns]); + + const itemIdToExpandedRowMap = useMemo(() => { + let itemIds = expandedRowItemIds; + if (expandAll) { + itemIds = items.map((i) => i[FIELD_NAME]).filter((f) => f !== undefined) as string[]; + } + return getItemIdToExpandedRowMap(itemIds, items); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expandAll, items, expandedRowItemIds]); + + return ( + + + className={'dataVisualizer'} + items={items} + itemId={FIELD_NAME} + columns={columns} + pagination={pagination} + sorting={sorting} + isExpandable={true} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + isSelectable={false} + onTableChange={onTableChange} + data-test-subj={'mlDataVisualizerTable'} + rowProps={(item) => ({ + 'data-test-subj': `mlDataVisualizerRow row-${item.fieldName}`, + })} + /> + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/color_range_legend.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/color_range_legend.tsx new file mode 100644 index 0000000000000..58be31a53e9c5 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/color_range_legend.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useRef, FC } from 'react'; +import d3 from 'd3'; + +import { EuiText } from '@elastic/eui'; + +const COLOR_RANGE_RESOLUTION = 10; + +interface ColorRangeLegendProps { + colorRange: (d: number) => string; + justifyTicks?: boolean; + showTicks?: boolean; + title?: string; + width?: number; +} + +/** + * Component to render a legend for color ranges to be used for color coding + * table cells and visualizations. + * + * This current version supports normalized value ranges (0-1) only. + * + * @param props ColorRangeLegendProps + */ +export const ColorRangeLegend: FC = ({ + colorRange, + justifyTicks = false, + showTicks = true, + title, + width = 250, +}) => { + const d3Container = useRef(null); + + const scale = d3.range(COLOR_RANGE_RESOLUTION + 1).map((d) => ({ + offset: (d / COLOR_RANGE_RESOLUTION) * 100, + stopColor: colorRange(d / COLOR_RANGE_RESOLUTION), + })); + + useEffect(() => { + if (d3Container.current === null) { + return; + } + + const wrapperHeight = 32; + const wrapperWidth = width; + + // top: 2 — adjust vertical alignment with title text + // bottom: 20 — room for axis ticks and labels + // left/right: 1 — room for first and last axis tick + // when justifyTicks is enabled, the left margin is increased to not cut off the first tick label + const margin = { top: 2, bottom: 20, left: justifyTicks || !showTicks ? 1 : 4, right: 1 }; + + const legendWidth = wrapperWidth - margin.left - margin.right; + const legendHeight = wrapperHeight - margin.top - margin.bottom; + + // remove, then redraw the legend + d3.select(d3Container.current).selectAll('*').remove(); + + const wrapper = d3 + .select(d3Container.current) + .classed('colorRangeLegend', true) + .attr('width', wrapperWidth) + .attr('height', wrapperHeight) + .append('g') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + // append gradient bar + const gradient = wrapper + .append('defs') + .append('linearGradient') + .attr('id', 'colorRangeGradient') + .attr('x1', '0%') + .attr('y1', '0%') + .attr('x2', '100%') + .attr('y2', '0%') + .attr('spreadMethod', 'pad'); + + scale.forEach(function (d) { + gradient + .append('stop') + .attr('offset', `${d.offset}%`) + .attr('stop-color', d.stopColor) + .attr('stop-opacity', 1); + }); + + wrapper + .append('rect') + .attr('x1', 0) + .attr('y1', 0) + .attr('width', legendWidth) + .attr('height', legendHeight) + .style('fill', 'url(#colorRangeGradient)'); + + const axisScale = d3.scale.linear().domain([0, 1]).range([0, legendWidth]); + + // Using this formatter ensures we get e.g. `0` and not `0.0`, but still `0.1`, `0.2` etc. + const tickFormat = d3.format(''); + const legendAxis = d3.svg + .axis() + .scale(axisScale) + .orient('bottom') + .tickFormat(tickFormat) + .tickSize(legendHeight + 4) + .ticks(legendWidth / 40); + + wrapper + .append('g') + .attr('class', 'legend axis') + .attr('transform', 'translate(0, 0)') + .call(legendAxis); + + // Adjust the alignment of the first and last tick text + // so that the tick labels don't overflow the color range. + if (justifyTicks || !showTicks) { + const text = wrapper.selectAll('text')[0]; + if (text.length > 1) { + d3.select(text[0]).style('text-anchor', 'start'); + d3.select(text[text.length - 1]).style('text-anchor', 'end'); + } + } + + if (!showTicks) { + wrapper.selectAll('.axis line').style('display', 'none'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(scale), d3Container.current]); + + if (title === undefined) { + return ; + } + + return ( + <> + +

{title}

+
+ + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/index.ts new file mode 100644 index 0000000000000..85d85f51a623f --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useDataVizChartTheme } from './use_data_viz_chart_theme'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_color_range.test.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_color_range.test.ts new file mode 100644 index 0000000000000..55888c607c287 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_color_range.test.ts @@ -0,0 +1,58 @@ +/* + * 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. + */ + +import { influencerColorScaleFactory } from './use_color_range'; + +describe('useColorRange', () => { + test('influencerColorScaleFactory(1)', () => { + const influencerColorScale = influencerColorScaleFactory(1); + + expect(influencerColorScale(0)).toBe(0); + expect(influencerColorScale(0.1)).toBe(0.1); + expect(influencerColorScale(0.2)).toBe(0.2); + expect(influencerColorScale(0.3)).toBe(0.3); + expect(influencerColorScale(0.4)).toBe(0.4); + expect(influencerColorScale(0.5)).toBe(0.5); + expect(influencerColorScale(0.6)).toBe(0.6); + expect(influencerColorScale(0.7)).toBe(0.7); + expect(influencerColorScale(0.8)).toBe(0.8); + expect(influencerColorScale(0.9)).toBe(0.9); + expect(influencerColorScale(1)).toBe(1); + }); + + test('influencerColorScaleFactory(2)', () => { + const influencerColorScale = influencerColorScaleFactory(2); + + expect(influencerColorScale(0)).toBe(0); + expect(influencerColorScale(0.1)).toBe(0); + expect(influencerColorScale(0.2)).toBe(0); + expect(influencerColorScale(0.3)).toBe(0); + expect(influencerColorScale(0.4)).toBe(0); + expect(influencerColorScale(0.5)).toBe(0); + expect(influencerColorScale(0.6)).toBe(0.04999999999999999); + expect(influencerColorScale(0.7)).toBe(0.09999999999999998); + expect(influencerColorScale(0.8)).toBe(0.15000000000000002); + expect(influencerColorScale(0.9)).toBe(0.2); + expect(influencerColorScale(1)).toBe(0.25); + }); + + test('influencerColorScaleFactory(3)', () => { + const influencerColorScale = influencerColorScaleFactory(3); + + expect(influencerColorScale(0)).toBe(0); + expect(influencerColorScale(0.1)).toBe(0); + expect(influencerColorScale(0.2)).toBe(0); + expect(influencerColorScale(0.3)).toBe(0); + expect(influencerColorScale(0.4)).toBe(0.05000000000000003); + expect(influencerColorScale(0.5)).toBe(0.125); + expect(influencerColorScale(0.6)).toBe(0.2); + expect(influencerColorScale(0.7)).toBe(0.27499999999999997); + expect(influencerColorScale(0.8)).toBe(0.35000000000000003); + expect(influencerColorScale(0.9)).toBe(0.425); + expect(influencerColorScale(1)).toBe(0.5); + }); +}); diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_color_range.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_color_range.ts new file mode 100644 index 0000000000000..e24134507e3a9 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_color_range.ts @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import d3 from 'd3'; +import { useMemo } from 'react'; +import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json'; +import euiThemeDark from '@elastic/eui/dist/eui_theme_dark.json'; + +import { i18n } from '@kbn/i18n'; + +import { useFileDataVisualizerKibana } from '../../../kibana_context'; + +/** + * Custom color scale factory that takes the amount of feature influencers + * into account to adjust the contrast of the color range. This is used for + * color coding for outlier detection where the amount of feature influencers + * affects the threshold from which the influencers value can actually be + * considered influential. + * + * @param n number of influencers + * @returns a function suitable as a preprocessor for d3.scale.linear() + */ +export const influencerColorScaleFactory = (n: number) => (t: number) => { + // for 1 influencer or less we fall back to a plain linear scale. + if (n <= 1) { + return t; + } + + if (t < 1 / n) { + return 0; + } + if (t < 3 / n) { + return (n / 4) * (t - 1 / n); + } + return 0.5 + (t - 3 / n); +}; + +export enum COLOR_RANGE_SCALE { + LINEAR = 'linear', + INFLUENCER = 'influencer', + SQRT = 'sqrt', +} + +/** + * Color range scale options in the format for EuiSelect's options prop. + */ +export const colorRangeScaleOptions = [ + { + value: COLOR_RANGE_SCALE.LINEAR, + text: i18n.translate('xpack.fileDataVisualizer.components.colorRangeLegend.linearScaleLabel', { + defaultMessage: 'Linear', + }), + }, + { + value: COLOR_RANGE_SCALE.INFLUENCER, + text: i18n.translate( + 'xpack.fileDataVisualizer.components.colorRangeLegend.influencerScaleLabel', + { + defaultMessage: 'Influencer custom scale', + } + ), + }, + { + value: COLOR_RANGE_SCALE.SQRT, + text: i18n.translate('xpack.fileDataVisualizer.components.colorRangeLegend.sqrtScaleLabel', { + defaultMessage: 'Sqrt', + }), + }, +]; + +export enum COLOR_RANGE { + BLUE = 'blue', + RED = 'red', + RED_GREEN = 'red-green', + GREEN_RED = 'green-red', + YELLOW_GREEN_BLUE = 'yellow-green-blue', +} + +/** + * Color range options in the format for EuiSelect's options prop. + */ +export const colorRangeOptions = [ + { + value: COLOR_RANGE.BLUE, + text: i18n.translate( + 'xpack.fileDataVisualizer.components.colorRangeLegend.blueColorRangeLabel', + { + defaultMessage: 'Blue', + } + ), + }, + { + value: COLOR_RANGE.RED, + text: i18n.translate( + 'xpack.fileDataVisualizer.components.colorRangeLegend.redColorRangeLabel', + { + defaultMessage: 'Red', + } + ), + }, + { + value: COLOR_RANGE.RED_GREEN, + text: i18n.translate( + 'xpack.fileDataVisualizer.components.colorRangeLegend.redGreenColorRangeLabel', + { + defaultMessage: 'Red - Green', + } + ), + }, + { + value: COLOR_RANGE.GREEN_RED, + text: i18n.translate( + 'xpack.fileDataVisualizer.components.colorRangeLegend.greenRedColorRangeLabel', + { + defaultMessage: 'Green - Red', + } + ), + }, + { + value: COLOR_RANGE.YELLOW_GREEN_BLUE, + text: i18n.translate( + 'xpack.fileDataVisualizer.components.colorRangeLegend.yellowGreenBlueColorRangeLabel', + { + defaultMessage: 'Yellow - Green - Blue', + } + ), + }, +]; + +/** + * A custom Yellow-Green-Blue color range to demonstrate the support + * for more complex ranges with more than two colors. + */ +const coloursYGB = [ + '#FFFFDD', + '#AAF191', + '#80D385', + '#61B385', + '#3E9583', + '#217681', + '#285285', + '#1F2D86', + '#000086', +]; +const colourRangeYGB = d3.range(0, 1, 1.0 / (coloursYGB.length - 1)); +colourRangeYGB.push(1); + +const colorDomains = { + [COLOR_RANGE.BLUE]: [0, 1], + [COLOR_RANGE.RED]: [0, 1], + [COLOR_RANGE.RED_GREEN]: [0, 1], + [COLOR_RANGE.GREEN_RED]: [0, 1], + [COLOR_RANGE.YELLOW_GREEN_BLUE]: colourRangeYGB, +}; + +/** + * Custom hook to get a d3 based color range to be used for color coding in table cells. + * + * @param colorRange COLOR_RANGE enum. + * @param colorRangeScale COLOR_RANGE_SCALE enum. + * @param featureCount + */ +export const useColorRange = ( + colorRange = COLOR_RANGE.BLUE, + colorRangeScale = COLOR_RANGE_SCALE.LINEAR, + featureCount = 1 +) => { + const { euiTheme } = useCurrentEuiTheme(); + + const colorRanges: Record = { + [COLOR_RANGE.BLUE]: [ + d3.rgb(euiTheme.euiColorEmptyShade).toString(), + d3.rgb(euiTheme.euiColorVis1).toString(), + ], + [COLOR_RANGE.RED]: [ + d3.rgb(euiTheme.euiColorEmptyShade).toString(), + d3.rgb(euiTheme.euiColorDanger).toString(), + ], + [COLOR_RANGE.RED_GREEN]: ['red', 'green'], + [COLOR_RANGE.GREEN_RED]: ['green', 'red'], + [COLOR_RANGE.YELLOW_GREEN_BLUE]: coloursYGB, + }; + + const linearScale = d3.scale + .linear() + .domain(colorDomains[colorRange]) + .range(colorRanges[colorRange]); + const influencerColorScale = influencerColorScaleFactory(featureCount); + const influencerScaleLinearWrapper = (n: number) => linearScale(influencerColorScale(n)); + + const scaleTypes = { + [COLOR_RANGE_SCALE.LINEAR]: linearScale, + [COLOR_RANGE_SCALE.INFLUENCER]: influencerScaleLinearWrapper, + [COLOR_RANGE_SCALE.SQRT]: d3.scale + .sqrt() + .domain(colorDomains[colorRange]) + // typings for .range() incorrectly don't allow passing in a color extent. + // @ts-ignore + .range(colorRanges[colorRange]), + }; + + return scaleTypes[colorRangeScale]; +}; + +export type EuiThemeType = typeof euiThemeLight | typeof euiThemeDark; + +export function useCurrentEuiTheme() { + const { + services: { uiSettings }, + } = useFileDataVisualizerKibana(); + return useMemo( + () => ({ euiTheme: uiSettings.get('theme:darkMode') ? euiThemeDark : euiThemeLight }), + [uiSettings] + ); +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_data_viz_chart_theme.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_data_viz_chart_theme.ts new file mode 100644 index 0000000000000..ad31ca2d09420 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/hooks/use_data_viz_chart_theme.ts @@ -0,0 +1,55 @@ +/* + * 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. + */ + +import type { PartialTheme } from '@elastic/charts'; +import { useMemo } from 'react'; +import { useCurrentEuiTheme } from './use_color_range'; +export const useDataVizChartTheme = (): PartialTheme => { + const { euiTheme } = useCurrentEuiTheme(); + const chartTheme = useMemo(() => { + const AREA_SERIES_COLOR = euiTheme.euiColorVis0; + return { + axes: { + tickLabel: { + fontSize: parseInt(euiTheme.euiFontSizeXS, 10), + fontFamily: euiTheme.euiFontFamily, + fontStyle: 'italic', + }, + }, + background: { color: 'transparent' }, + chartMargins: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + chartPaddings: { + left: 0, + right: 0, + top: 4, + bottom: 0, + }, + scales: { barsPadding: 0.1 }, + colors: { + vizColors: [AREA_SERIES_COLOR], + }, + areaSeriesStyle: { + line: { + strokeWidth: 1, + visible: true, + }, + point: { + visible: false, + radius: 0, + opacity: 0, + }, + area: { visible: true, opacity: 1 }, + }, + }; + }, [euiTheme]); + return chartTheme; +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/index.ts new file mode 100644 index 0000000000000..3009470af4858 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DataVisualizerTable, ItemIdToExpandedRowMap } from './data_visualizer_stats_table'; diff --git a/x-pack/plugins/security_solution/common/graphql/root/schema.gql.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/field_data_row.ts similarity index 59% rename from x-pack/plugins/security_solution/common/graphql/root/schema.gql.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/field_data_row.ts index 16fdeb7ec6c75..24209af23ceb4 100644 --- a/x-pack/plugins/security_solution/common/graphql/root/schema.gql.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/field_data_row.ts @@ -5,15 +5,8 @@ * 2.0. */ -import gql from 'graphql-tag'; +import type { FieldVisConfig, FileBasedFieldVisConfig } from './field_vis_config'; -export const rootSchema = gql` - schema { - query: Query - mutation: Mutation - } - - type Query - - type Mutation -`; +export interface FieldDataRowProps { + config: FieldVisConfig | FileBasedFieldVisConfig; +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/field_vis_config.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/field_vis_config.ts new file mode 100644 index 0000000000000..e9ef0cd75e286 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/field_vis_config.ts @@ -0,0 +1,98 @@ +/* + * 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. + */ + +import { JobFieldType } from '../../../../../common'; + +export interface Percentile { + percent: number; + minValue: number; + maxValue: number; +} + +export interface MetricFieldVisStats { + avg?: number; + distribution?: { + percentiles: Percentile[]; + maxPercentile: number; + minPercentile: 0; + }; + max?: number; + median?: number; + min?: number; +} + +interface DocumentCountBuckets { + [key: string]: number; +} + +export interface FieldVisStats { + cardinality?: number; + count?: number; + sampleCount?: number; + trueCount?: number; + falseCount?: number; + earliest?: number; + latest?: number; + documentCounts?: { + buckets?: DocumentCountBuckets; + }; + avg?: number; + distribution?: { + percentiles: Percentile[]; + maxPercentile: number; + minPercentile: 0; + }; + fieldName?: string; + isTopValuesSampled?: boolean; + max?: number; + median?: number; + min?: number; + topValues?: Array<{ key: number | string; doc_count: number }>; + topValuesSampleSize?: number; + topValuesSamplerShardSize?: number; + examples?: Array; + timeRangeEarliest?: number; + timeRangeLatest?: number; +} + +// The internal representation of the configuration used to build the visuals +// which display the field information. +export interface FieldVisConfig { + type: JobFieldType; + fieldName?: string; + existsInDocs: boolean; + aggregatable: boolean; + loading: boolean; + stats?: FieldVisStats; + fieldFormat?: any; + isUnsupportedType?: boolean; +} + +export interface FileBasedFieldVisConfig { + type: JobFieldType; + fieldName?: string; + stats?: FieldVisStats; + format?: string; +} + +export interface FileBasedUnknownFieldVisConfig { + fieldName: string; + type: 'text' | 'unknown'; + stats: { mean: number; count: number; sampleCount: number; cardinality: number }; +} + +export function isFileBasedFieldVisConfig( + field: FieldVisConfig | FileBasedFieldVisConfig +): field is FileBasedFieldVisConfig { + return !field.hasOwnProperty('existsInDocs'); +} + +export function isIndexBasedFieldVisConfig( + field: FieldVisConfig | FileBasedFieldVisConfig +): field is FieldVisConfig { + return field.hasOwnProperty('existsInDocs'); +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/index.ts new file mode 100644 index 0000000000000..161829461aa26 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/types/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { FieldDataRowProps } from './field_data_row'; +export { + FieldVisConfig, + FileBasedFieldVisConfig, + FieldVisStats, + MetricFieldVisStats, + isFileBasedFieldVisConfig, + isIndexBasedFieldVisConfig, +} from './field_vis_config'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/use_table_settings.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/use_table_settings.ts new file mode 100644 index 0000000000000..e2ff18a8001aa --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/use_table_settings.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Direction, EuiBasicTableProps, Pagination, PropertySort } from '@elastic/eui'; +import { useCallback, useMemo } from 'react'; + +import { DataVisualizerTableState } from '../../../../common'; + +const PAGE_SIZE_OPTIONS = [10, 25, 50]; + +interface UseTableSettingsReturnValue { + onTableChange: EuiBasicTableProps['onChange']; + pagination: Pagination; + sorting: { sort: PropertySort }; +} + +export function useTableSettings( + items: TypeOfItem[], + pageState: DataVisualizerTableState, + updatePageState: (update: DataVisualizerTableState) => void +): UseTableSettingsReturnValue { + const { pageIndex, pageSize, sortField, sortDirection } = pageState; + + const onTableChange: EuiBasicTableProps['onChange'] = useCallback( + ({ page, sort }) => { + const result = { + ...pageState, + pageIndex: page?.index ?? pageState.pageIndex, + pageSize: page?.size ?? pageState.pageSize, + sortField: (sort?.field as string) ?? pageState.sortField, + sortDirection: sort?.direction ?? pageState.sortDirection, + }; + updatePageState(result); + }, + [pageState, updatePageState] + ); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: items.length, + pageSizeOptions: PAGE_SIZE_OPTIONS, + }), + [items, pageIndex, pageSize] + ); + + const sorting = useMemo( + () => ({ + sort: { + field: sortField as string, + direction: sortDirection as Direction, + }, + }), + [sortField, sortDirection] + ); + + return { onTableChange, pagination, sorting }; +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/utils.ts b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/utils.ts new file mode 100644 index 0000000000000..27da91153b3ba --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/utils.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FileBasedFieldVisConfig } from './types'; + +export const getTFPercentage = (config: FileBasedFieldVisConfig) => { + const { stats } = config; + if (stats === undefined) return null; + const { count } = stats; + // use stats from index based config + let { trueCount, falseCount } = stats; + + // use stats from file based find structure results + if (stats.trueCount === undefined || stats.falseCount === undefined) { + if (config?.stats?.topValues) { + config.stats.topValues.forEach((doc) => { + if (doc.doc_count !== undefined) { + if (doc.key.toString().toLowerCase() === 'false') { + falseCount = doc.doc_count; + } + if (doc.key.toString().toLowerCase() === 'true') { + trueCount = doc.doc_count; + } + } + }); + } + } + if (count === undefined || trueCount === undefined || falseCount === undefined) return null; + return { + count, + trueCount, + falseCount, + }; +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/top_values/_top_values.scss b/x-pack/plugins/file_data_visualizer/public/application/components/top_values/_top_values.scss new file mode 100644 index 0000000000000..05fa1bfa94b2d --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/top_values/_top_values.scss @@ -0,0 +1,19 @@ +.fieldDataTopValuesContainer { + padding-top: $euiSizeXS; +} + +.topValuesValueLabelContainer { + margin-right: $euiSizeM; + &.topValuesValueLabelContainer--small { + width:70px; + } + + &.topValuesValueLabelContainer--large { + width: 200px; + } +} + +.topValuesPercentLabelContainer { + margin-left: $euiSizeM; + width:70px; +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/top_values/index.ts similarity index 85% rename from x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/index.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/top_values/index.ts index 8c6527e90c709..c006b37fe2794 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/index.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/top_values/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './flyout_drilldown_wizard'; +export { TopValues } from './top_values'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/top_values/top_values.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/top_values/top_values.tsx new file mode 100644 index 0000000000000..c1815fad41de8 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/top_values/top_values.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, Fragment } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiSpacer, + EuiText, + EuiToolTip, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import classNames from 'classnames'; +import { roundToDecimalPlace, kibanaFieldFormat } from '../utils'; +import { ExpandedRowFieldHeader } from '../stats_table/components/expanded_row_field_header'; +import { FieldVisStats } from '../stats_table/types'; + +interface Props { + stats: FieldVisStats | undefined; + fieldFormat?: any; + barColor?: 'primary' | 'secondary' | 'danger' | 'subdued' | 'accent'; + compressed?: boolean; +} + +function getPercentLabel(docCount: number, topValuesSampleSize: number): string { + const percent = (100 * docCount) / topValuesSampleSize; + if (percent >= 0.1) { + return `${roundToDecimalPlace(percent, 1)}%`; + } else { + return '< 0.1%'; + } +} + +export const TopValues: FC = ({ stats, fieldFormat, barColor, compressed }) => { + if (stats === undefined) return null; + const { + topValues, + topValuesSampleSize, + topValuesSamplerShardSize, + count, + isTopValuesSampled, + } = stats; + const progressBarMax = isTopValuesSampled === true ? topValuesSampleSize : count; + return ( + + + + + +
+ {Array.isArray(topValues) && + topValues.map((value) => ( + + + + + {kibanaFieldFormat(value.key, fieldFormat)} + + + + + + + {progressBarMax !== undefined && ( + + + {getPercentLabel(value.doc_count, progressBarMax)} + + + )} + + ))} + {isTopValuesSampled === true && ( + + + + + + + )} +
+
+ ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/utils/format_value.ts b/x-pack/plugins/file_data_visualizer/public/application/components/utils/format_value.ts new file mode 100644 index 0000000000000..5e12302a598ff --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/utils/format_value.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Formatter for 'typical' and 'actual' values from machine learning results. + * For detectors which use the time_of_week or time_of_day + * functions, the filter converts the raw number, which is the number of seconds since + * midnight, into a human-readable date/time format. + */ + +import moment from 'moment'; +const SIGFIGS_IF_ROUNDING = 3; // Number of sigfigs to use for values < 10 + +// Formats a single value according to the specified ML function. +// If a Kibana fieldFormat is not supplied, will fall back to default +// formatting depending on the magnitude of the value. +// For time_of_day or time_of_week functions the anomaly record +// containing the timestamp of the anomaly should be supplied in +// order to correctly format the day or week offset to the time of the anomaly. +export function formatSingleValue( + value: number, + func?: string, + fieldFormat?: any, + record?: any // TODO remove record, not needed for file upload +) { + if (value === undefined || value === null) { + return ''; + } + + // If the analysis function is time_of_week/day, format as day/time. + // For time_of_week / day, actual / typical is the UTC offset in seconds from the + // start of the week / day, so need to manipulate to UTC moment of the start of the week / day + // that the anomaly occurred using record timestamp if supplied, add on the offset, and finally + // revert back to configured timezone for formatting. + if (func === 'time_of_week') { + const d = + record !== undefined && record.timestamp !== undefined + ? new Date(record.timestamp) + : new Date(); + const utcMoment = moment.utc(d).startOf('week').add(value, 's'); + return moment(utcMoment.valueOf()).format('ddd HH:mm'); + } else if (func === 'time_of_day') { + const d = + record !== undefined && record.timestamp !== undefined + ? new Date(record.timestamp) + : new Date(); + const utcMoment = moment.utc(d).startOf('day').add(value, 's'); + return moment(utcMoment.valueOf()).format('HH:mm'); + } else { + if (fieldFormat !== undefined) { + return fieldFormat.convert(value, 'text'); + } else { + // If no Kibana FieldFormat object provided, + // format the value depending on its magnitude. + const absValue = Math.abs(value); + if (absValue >= 10000 || absValue === Math.floor(absValue)) { + // Output 0 decimal places if whole numbers or >= 10000 + if (fieldFormat !== undefined) { + return fieldFormat.convert(value, 'text'); + } else { + return Number(value.toFixed(0)); + } + } else if (absValue >= 10) { + // Output to 1 decimal place between 10 and 10000 + return Number(value.toFixed(1)); + } else { + // For values < 10, output to 3 significant figures + let multiple; + if (value > 0) { + multiple = Math.pow( + 10, + SIGFIGS_IF_ROUNDING - Math.floor(Math.log(value) / Math.LN10) - 1 + ); + } else { + multiple = Math.pow( + 10, + SIGFIGS_IF_ROUNDING - Math.floor(Math.log(-1 * value) / Math.LN10) - 1 + ); + } + return Math.round(value * multiple) / multiple; + } + } + } +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/utils/index.ts b/x-pack/plugins/file_data_visualizer/public/application/components/utils/index.ts new file mode 100644 index 0000000000000..b4c491eee8fd4 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/utils/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { createUrlOverrides, processResults, readFile, DEFAULT_LINES_TO_SAMPLE } from './utils'; +export { roundToDecimalPlace } from './round_to_decimal_place'; +export { kibanaFieldFormat } from './kibana_field_format'; +export { numberAsOrdinal } from './number_as_ordinal'; +export { formatSingleValue } from './format_value'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/utils/kibana_field_format.ts b/x-pack/plugins/file_data_visualizer/public/application/components/utils/kibana_field_format.ts new file mode 100644 index 0000000000000..0218b7d62655c --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/utils/kibana_field_format.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Formatter which uses the fieldFormat object of a Kibana index pattern + * field to format the value of a field. + */ + +export function kibanaFieldFormat(value: any, fieldFormat: any) { + if (fieldFormat !== undefined && fieldFormat !== null) { + return fieldFormat.convert(value, 'text'); + } else { + return value; + } +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/utils/number_as_ordinal.test.ts b/x-pack/plugins/file_data_visualizer/public/application/components/utils/number_as_ordinal.test.ts new file mode 100644 index 0000000000000..6990bf0923ac3 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/utils/number_as_ordinal.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { numberAsOrdinal } from './number_as_ordinal'; + +describe('numberAsOrdinal formatter', () => { + const tests = [ + { number: 0, asOrdinal: '0th' }, + { number: 1, asOrdinal: '1st' }, + { number: 2.2, asOrdinal: '2nd' }, + { number: 3, asOrdinal: '3rd' }, + { number: 5, asOrdinal: '5th' }, + { number: 10, asOrdinal: '10th' }, + { number: 11, asOrdinal: '11th' }, + { number: 22, asOrdinal: '22nd' }, + { number: 33, asOrdinal: '33rd' }, + { number: 44.4, asOrdinal: '44th' }, + { number: 100, asOrdinal: '100th' }, + ]; + test('returns the expected numeral format', () => { + tests.forEach((test) => { + expect(numberAsOrdinal(test.number)).toBe(test.asOrdinal); + }); + }); +}); diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/utils/number_as_ordinal.ts b/x-pack/plugins/file_data_visualizer/public/application/components/utils/number_as_ordinal.ts new file mode 100644 index 0000000000000..3a2707cc47783 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/utils/number_as_ordinal.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// @ts-ignore +import numeral from '@elastic/numeral'; + +/** + * Formats the supplied number as ordinal e.g. 15 as 15th. + * Formatting first converts the supplied number to an integer by flooring. + * @param {number} value to format as an ordinal + * @return {string} number formatted as an ordinal e.g. 15th + */ +export function numberAsOrdinal(num: number) { + const int = Math.floor(num); + return `${numeral(int).format('0o')}`; +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/utils/round_to_decimal_place.test.ts b/x-pack/plugins/file_data_visualizer/public/application/components/utils/round_to_decimal_place.test.ts new file mode 100644 index 0000000000000..151ae93a93815 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/utils/round_to_decimal_place.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { roundToDecimalPlace } from './round_to_decimal_place'; + +describe('roundToDecimalPlace formatter', () => { + it('returns the correct format using default decimal place', () => { + expect(roundToDecimalPlace(12)).toBe(12); + expect(roundToDecimalPlace(12.3)).toBe(12.3); + expect(roundToDecimalPlace(12.34)).toBe(12.34); + expect(roundToDecimalPlace(12.345)).toBe(12.35); + expect(roundToDecimalPlace(12.045)).toBe(12.05); + expect(roundToDecimalPlace(12.005)).toBe(12.01); + expect(roundToDecimalPlace(12.0005)).toBe(12); + expect(roundToDecimalPlace(0.05)).toBe(0.05); + expect(roundToDecimalPlace(0.005)).toBe('5.00e-3'); + expect(roundToDecimalPlace(0.0005)).toBe('5.00e-4'); + expect(roundToDecimalPlace(-0.0005)).toBe('-5.00e-4'); + expect(roundToDecimalPlace(-12.045)).toBe(-12.04); + expect(roundToDecimalPlace(0)).toBe(0); + }); + + it('returns the correct format using specified decimal place', () => { + expect(roundToDecimalPlace(12, 4)).toBe(12); + expect(roundToDecimalPlace(12.3, 4)).toBe(12.3); + expect(roundToDecimalPlace(12.3456, 4)).toBe(12.3456); + expect(roundToDecimalPlace(12.345678, 4)).toBe(12.3457); + expect(roundToDecimalPlace(0.05, 4)).toBe(0.05); + expect(roundToDecimalPlace(0.0005, 4)).toBe(0.0005); + expect(roundToDecimalPlace(0.00005, 4)).toBe('5.00e-5'); + expect(roundToDecimalPlace(-0.00005, 4)).toBe('-5.00e-5'); + expect(roundToDecimalPlace(0, 4)).toBe(0); + }); +}); diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/utils/round_to_decimal_place.ts b/x-pack/plugins/file_data_visualizer/public/application/components/utils/round_to_decimal_place.ts new file mode 100644 index 0000000000000..88ab605a95369 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/utils/round_to_decimal_place.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function roundToDecimalPlace(num?: number, dp: number = 2): number | string { + if (num === undefined) return ''; + if (num % 1 === 0) { + // no decimal place + return num; + } + + if (Math.abs(num) < Math.pow(10, -dp)) { + return Number.parseFloat(String(num)).toExponential(2); + } + const m = Math.pow(10, dp); + return Math.round(num * m) / m; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts b/x-pack/plugins/file_data_visualizer/public/application/components/utils/utils.ts similarity index 96% rename from x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts rename to x-pack/plugins/file_data_visualizer/public/application/components/utils/utils.ts index 49e5da565b927..1d47e633188c5 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts +++ b/x-pack/plugins/file_data_visualizer/public/application/components/utils/utils.ts @@ -6,8 +6,7 @@ */ import { isEqual } from 'lodash'; -import { AnalysisResult, InputOverrides } from '../../../../../../../file_upload/common'; -import { MB } from '../../../../../../../file_upload/public'; +import { AnalysisResult, InputOverrides, MB } from '../../../../../file_upload/common'; export const DEFAULT_LINES_TO_SAMPLE = 1000; const UPLOAD_SIZE_MB = 5; diff --git a/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx b/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx new file mode 100644 index 0000000000000..f291076557bb8 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import './_index.scss'; +import React, { FC } from 'react'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { getCoreStart, getPluginsStart } from '../kibana_services'; + +// @ts-ignore +import { FileDataVisualizerView } from './components/file_datavisualizer_view/index'; + +export const FileDataVisualizer: FC = () => { + const coreStart = getCoreStart(); + const { data, maps, embeddable, share, security, fileUpload } = getPluginsStart(); + const services = { data, maps, embeddable, share, security, fileUpload, ...coreStart }; + + return ( + + + + ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/index.ts b/x-pack/plugins/file_data_visualizer/public/application/index.ts new file mode 100644 index 0000000000000..dba820519af94 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { FileDataVisualizer } from './file_datavisualizer'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/kibana_context.ts b/x-pack/plugins/file_data_visualizer/public/application/kibana_context.ts new file mode 100644 index 0000000000000..6752c322d42e3 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/kibana_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from 'kibana/public'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import type { FileDataVisualizerStartDependencies } from '../plugin'; + +export type StartServices = CoreStart & FileDataVisualizerStartDependencies; +export const useFileDataVisualizerKibana = () => useKibana(); diff --git a/x-pack/plugins/file_data_visualizer/public/application/shared_imports.ts b/x-pack/plugins/file_data_visualizer/public/application/shared_imports.ts new file mode 100644 index 0000000000000..20481d2fde9be --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/shared_imports.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { XJson } from '../../../../../src/plugins/es_ui_shared/public'; +const { collapseLiteralStrings, expandLiteralStrings } = XJson; + +export { XJsonMode } from '@kbn/ace'; +export { collapseLiteralStrings, expandLiteralStrings }; diff --git a/x-pack/plugins/file_data_visualizer/public/application/util/field_types_utils.test.ts b/x-pack/plugins/file_data_visualizer/public/application/util/field_types_utils.test.ts new file mode 100644 index 0000000000000..6f81c0bf4e7d3 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/util/field_types_utils.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { JOB_FIELD_TYPES } from '../../../common'; +import { getJobTypeAriaLabel, jobTypeAriaLabels } from './field_types_utils'; + +describe('field type utils', () => { + describe('getJobTypeAriaLabel: Getting a field type aria label by passing what it is stored in constants', () => { + test('should returns all JOB_FIELD_TYPES labels exactly as it is for each correct value', () => { + const keys = Object.keys(JOB_FIELD_TYPES); + const receivedLabels: Record = {}; + const testStorage = jobTypeAriaLabels; + keys.forEach((constant) => { + receivedLabels[constant] = getJobTypeAriaLabel( + JOB_FIELD_TYPES[constant as keyof typeof JOB_FIELD_TYPES] + ); + }); + + expect(receivedLabels).toEqual(testStorage); + }); + test('should returns NULL as JOB_FIELD_TYPES does not contain such a keyword', () => { + expect(getJobTypeAriaLabel('JOB_FIELD_TYPES')).toBe(null); + }); + }); +}); diff --git a/x-pack/plugins/file_data_visualizer/public/application/util/field_types_utils.ts b/x-pack/plugins/file_data_visualizer/public/application/util/field_types_utils.ts new file mode 100644 index 0000000000000..76a5f6ac20117 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/util/field_types_utils.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { JOB_FIELD_TYPES } from '../../../common'; + +export const jobTypeAriaLabels = { + BOOLEAN: i18n.translate('xpack.fileDataVisualizer.fieldTypeIcon.booleanTypeAriaLabel', { + defaultMessage: 'boolean type', + }), + DATE: i18n.translate('xpack.fileDataVisualizer.fieldTypeIcon.dateTypeAriaLabel', { + defaultMessage: 'date type', + }), + GEO_POINT: i18n.translate('xpack.fileDataVisualizer.fieldTypeIcon.geoPointTypeAriaLabel', { + defaultMessage: '{geoPointParam} type', + values: { + geoPointParam: 'geo point', + }, + }), + IP: i18n.translate('xpack.fileDataVisualizer.fieldTypeIcon.ipTypeAriaLabel', { + defaultMessage: 'ip type', + }), + KEYWORD: i18n.translate('xpack.fileDataVisualizer.fieldTypeIcon.keywordTypeAriaLabel', { + defaultMessage: 'keyword type', + }), + NUMBER: i18n.translate('xpack.fileDataVisualizer.fieldTypeIcon.numberTypeAriaLabel', { + defaultMessage: 'number type', + }), + TEXT: i18n.translate('xpack.fileDataVisualizer.fieldTypeIcon.textTypeAriaLabel', { + defaultMessage: 'text type', + }), + UNKNOWN: i18n.translate('xpack.fileDataVisualizer.fieldTypeIcon.unknownTypeAriaLabel', { + defaultMessage: 'unknown type', + }), +}; + +export const getJobTypeAriaLabel = (type: string) => { + const requestedFieldType = Object.keys(JOB_FIELD_TYPES).find( + (k) => JOB_FIELD_TYPES[k as keyof typeof JOB_FIELD_TYPES] === type + ); + if (requestedFieldType === undefined) { + return null; + } + return jobTypeAriaLabels[requestedFieldType as keyof typeof jobTypeAriaLabels]; +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/util/get_max_bytes.ts b/x-pack/plugins/file_data_visualizer/public/application/util/get_max_bytes.ts new file mode 100644 index 0000000000000..821a94bf5166d --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/util/get_max_bytes.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getPluginsStart } from '../../kibana_services'; + +// expose the fileUpload plugin's getMaxBytesFormatted for use in ML +// so ML doesn't need to depend on the fileUpload plugin for this one function +export function getMaxBytesFormatted() { + return getPluginsStart().fileUpload.getMaxBytesFormatted(); +} diff --git a/x-pack/plugins/file_data_visualizer/public/index.ts b/x-pack/plugins/file_data_visualizer/public/index.ts new file mode 100644 index 0000000000000..64a81936dbbde --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FileDataVisualizerPlugin } from './plugin'; + +export function plugin() { + return new FileDataVisualizerPlugin(); +} + +export { FileDataVisualizerPluginStart } from './plugin'; diff --git a/x-pack/plugins/file_data_visualizer/public/kibana_services.ts b/x-pack/plugins/file_data_visualizer/public/kibana_services.ts new file mode 100644 index 0000000000000..6a5fe85c72477 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/kibana_services.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from 'kibana/public'; +import { FileDataVisualizerStartDependencies } from './plugin'; + +let coreStart: CoreStart; +let pluginsStart: FileDataVisualizerStartDependencies; +export function setStartServices(core: CoreStart, plugins: FileDataVisualizerStartDependencies) { + coreStart = core; + pluginsStart = plugins; +} + +export const getCoreStart = () => coreStart; +export const getPluginsStart = () => pluginsStart; diff --git a/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/index.ts new file mode 100644 index 0000000000000..99dbb6d3746ce --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from 'src/core/public'; +import { FileDataVisualizer } from '../application'; +import { getCoreStart } from '../kibana_services'; + +let loadModulesPromise: Promise; + +interface LazyLoadedModules { + FileDataVisualizer: typeof FileDataVisualizer; + getHttp: () => HttpSetup; +} + +export async function lazyLoadModules(): Promise { + if (typeof loadModulesPromise !== 'undefined') { + return loadModulesPromise; + } + + loadModulesPromise = new Promise(async (resolve) => { + const lazyImports = await import('./lazy'); + + resolve({ + ...lazyImports, + getHttp: () => getCoreStart().http, + }); + }); + return loadModulesPromise; +} diff --git a/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/lazy/index.ts new file mode 100644 index 0000000000000..4229b95f3aaad --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/lazy/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { FileDataVisualizer } from '../../application'; diff --git a/x-pack/plugins/file_data_visualizer/public/plugin.ts b/x-pack/plugins/file_data_visualizer/public/plugin.ts new file mode 100644 index 0000000000000..a94c0fce45cd4 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/plugin.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from 'kibana/public'; +import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import type { SharePluginStart } from '../../../../src/plugins/share/public'; +import { Plugin } from '../../../../src/core/public'; + +import { setStartServices } from './kibana_services'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { FileUploadPluginStart } from '../../file_upload/public'; +import type { MapsStartApi } from '../../maps/public'; +import type { SecurityPluginSetup } from '../../security/public'; +import { getFileDataVisualizerComponent } from './api'; +import { getMaxBytesFormatted } from './application/util/get_max_bytes'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface FileDataVisualizerSetupDependencies {} +export interface FileDataVisualizerStartDependencies { + data: DataPublicPluginStart; + fileUpload: FileUploadPluginStart; + maps: MapsStartApi; + embeddable: EmbeddableStart; + security?: SecurityPluginSetup; + share: SharePluginStart; +} + +export type FileDataVisualizerPluginSetup = ReturnType; +export type FileDataVisualizerPluginStart = ReturnType; + +export class FileDataVisualizerPlugin + implements + Plugin< + FileDataVisualizerPluginSetup, + FileDataVisualizerPluginStart, + FileDataVisualizerSetupDependencies, + FileDataVisualizerStartDependencies + > { + public setup() {} + + public start(core: CoreStart, plugins: FileDataVisualizerStartDependencies) { + setStartServices(core, plugins); + return { getFileDataVisualizerComponent, getMaxBytesFormatted }; + } +} diff --git a/x-pack/plugins/security_solution/server/graphql/scalar_to_any/schema.gql.ts b/x-pack/plugins/file_data_visualizer/server/index.ts similarity index 69% rename from x-pack/plugins/security_solution/server/graphql/scalar_to_any/schema.gql.ts rename to x-pack/plugins/file_data_visualizer/server/index.ts index 79c4a881c10b9..43067dbe99d0d 100644 --- a/x-pack/plugins/security_solution/server/graphql/scalar_to_any/schema.gql.ts +++ b/x-pack/plugins/file_data_visualizer/server/index.ts @@ -5,8 +5,6 @@ * 2.0. */ -import gql from 'graphql-tag'; +import { FileDataVisualizerPlugin } from './plugin'; -export const toAnySchema = gql` - scalar ToAny -`; +export const plugin = () => new FileDataVisualizerPlugin(); diff --git a/x-pack/plugins/security_solution/server/graphql/scalar_to_date_array/schema.gql.ts b/x-pack/plugins/file_data_visualizer/server/plugin.ts similarity index 66% rename from x-pack/plugins/security_solution/server/graphql/scalar_to_date_array/schema.gql.ts rename to x-pack/plugins/file_data_visualizer/server/plugin.ts index 7238e975c4c25..f6893b7edaa53 100644 --- a/x-pack/plugins/security_solution/server/graphql/scalar_to_date_array/schema.gql.ts +++ b/x-pack/plugins/file_data_visualizer/server/plugin.ts @@ -5,8 +5,9 @@ * 2.0. */ -import gql from 'graphql-tag'; +import { Plugin } from 'src/core/server'; -export const toDateSchema = gql` - scalar ToDateArray -`; +export class FileDataVisualizerPlugin implements Plugin { + setup() {} + start() {} +} diff --git a/x-pack/plugins/file_data_visualizer/tsconfig.json b/x-pack/plugins/file_data_visualizer/tsconfig.json new file mode 100644 index 0000000000000..2d668bcaa2045 --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["common/**/*", "public/**/*", "server/**/*"], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../file_upload/tsconfig.json" }, + { "path": "../maps/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/file_upload/common/constants.ts b/x-pack/plugins/file_upload/common/constants.ts index ea36e51466703..977f969647658 100644 --- a/x-pack/plugins/file_upload/common/constants.ts +++ b/x-pack/plugins/file_upload/common/constants.ts @@ -16,4 +16,4 @@ export const FILE_SIZE_DISPLAY_FORMAT = '0,0.[0] b'; // Value to use in the Elasticsearch index mapping meta data to identify the // index as having been created by the ML File Data Visualizer. -export const INDEX_META_DATA_CREATED_BY = 'ml-file-data-visualizer'; +export const INDEX_META_DATA_CREATED_BY = 'file-data-visualizer'; diff --git a/x-pack/plugins/file_upload/common/types.ts b/x-pack/plugins/file_upload/common/types.ts index 11cf4ac3615bf..e10b9e90a71d8 100644 --- a/x-pack/plugins/file_upload/common/types.ts +++ b/x-pack/plugins/file_upload/common/types.ts @@ -6,11 +6,7 @@ */ import type { estypes } from '@elastic/elasticsearch'; -import { ES_FIELD_TYPES } from '../../../../src/plugins/data/common'; - -export interface HasImportPermission { - hasImportPermission: boolean; -} +import { ES_FIELD_TYPES } from 'src/plugins/data/common'; export interface InputOverrides { [key: string]: string | undefined; @@ -75,6 +71,28 @@ export interface FindFileStructureResponse { should_trim_fields?: boolean; } +export interface FindFileStructureErrorResponse { + body: { + statusCode: number; + error: string; + message: string; + attributes?: ErrorAttribute; + }; + name: string; +} + +interface ErrorAttribute { + body: { + error: { + suppressed: Array<{ reason: string }>; + }; + }; +} + +export interface HasImportPermission { + hasImportPermission: boolean; +} + export type InputData = any[]; export interface ImportResponse { diff --git a/x-pack/plugins/file_upload/kibana.json b/x-pack/plugins/file_upload/kibana.json index a1c585e534333..6f93874cdbcaa 100644 --- a/x-pack/plugins/file_upload/kibana.json +++ b/x-pack/plugins/file_upload/kibana.json @@ -4,7 +4,17 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "usageCollection"], - "optionalPlugins": ["security"], - "requiredBundles": ["kibanaReact"] + "requiredPlugins": [ + "data", + "usageCollection" + ], + "optionalPlugins": [ + "security" + ], + "requiredBundles": [ + "kibanaReact" + ], + "extraPublicDirs": [ + "common" + ] } diff --git a/x-pack/plugins/file_upload/public/api/index.ts b/x-pack/plugins/file_upload/public/api/index.ts index 281537cbbde16..23eeb9abde324 100644 --- a/x-pack/plugins/file_upload/public/api/index.ts +++ b/x-pack/plugins/file_upload/public/api/index.ts @@ -6,22 +6,32 @@ */ import React from 'react'; -import { FileUploadComponentProps, lazyLoadFileUploadModules } from '../lazy_load_bundle'; +import { FileUploadComponentProps, lazyLoadModules } from '../lazy_load_bundle'; import type { IImporter, ImportFactoryOptions } from '../importer'; -import { HasImportPermission } from '../../common'; +import type { HasImportPermission, FindFileStructureResponse } from '../../common'; +import type { getMaxBytes, getMaxBytesFormatted } from '../importer/get_max_bytes'; export interface FileUploadStartApi { - getFileUploadComponent(): Promise>; - importerFactory(format: string, options: ImportFactoryOptions): Promise; - getMaxBytes(): number; - getMaxBytesFormatted(): string; - hasImportPermission(params: HasImportPermissionParams): Promise; + getFileUploadComponent(): ReturnType; + importerFactory: typeof importerFactory; + getMaxBytes: typeof getMaxBytes; + getMaxBytesFormatted: typeof getMaxBytesFormatted; + hasImportPermission: typeof hasImportPermission; + checkIndexExists: typeof checkIndexExists; + getTimeFieldRange: typeof getTimeFieldRange; + analyzeFile: typeof analyzeFile; +} + +export interface GetTimeFieldRangeResponse { + success: boolean; + start: { epoch: number; string: string }; + end: { epoch: number; string: string }; } export async function getFileUploadComponent(): Promise< React.ComponentType > { - const fileUploadModules = await lazyLoadFileUploadModules(); + const fileUploadModules = await lazyLoadModules(); return fileUploadModules.JsonUploadAndParse; } @@ -29,7 +39,7 @@ export async function importerFactory( format: string, options: ImportFactoryOptions ): Promise { - const fileUploadModules = await lazyLoadFileUploadModules(); + const fileUploadModules = await lazyLoadModules(); return fileUploadModules.importerFactory(format, options); } @@ -39,8 +49,22 @@ interface HasImportPermissionParams { indexName?: string; } +export async function analyzeFile( + file: string, + params: Record = {} +): Promise { + const { getHttp } = await lazyLoadModules(); + const body = JSON.stringify(file); + return await getHttp().fetch({ + path: `/internal/file_data_visualizer/analyze_file`, + method: 'POST', + body, + query: params, + }); +} + export async function hasImportPermission(params: HasImportPermissionParams): Promise { - const fileUploadModules = await lazyLoadFileUploadModules(); + const fileUploadModules = await lazyLoadModules(); try { const resp = await fileUploadModules.getHttp().fetch({ path: `/internal/file_upload/has_import_permission`, @@ -52,3 +76,29 @@ export async function hasImportPermission(params: HasImportPermissionParams): Pr return false; } } + +export async function checkIndexExists( + index: string, + params: Record = {} +): Promise { + const body = JSON.stringify({ index }); + const fileUploadModules = await lazyLoadModules(); + const { exists } = await fileUploadModules.getHttp().fetch<{ exists: boolean }>({ + path: `/internal/file_upload/index_exists`, + method: 'POST', + body, + query: params, + }); + return exists; +} + +export async function getTimeFieldRange(index: string, query: unknown, timeFieldName?: string) { + const body = JSON.stringify({ index, timeFieldName, query }); + + const fileUploadModules = await lazyLoadModules(); + return await fileUploadModules.getHttp().fetch({ + path: `/internal/file_upload/time_field_range`, + method: 'POST', + body, + }); +} diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx index 2f31bc47b899c..6cd55e3a0a74a 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx @@ -9,7 +9,7 @@ import React, { Component } from 'react'; import { EuiFilePicker, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { MB } from '../../../common'; -import { getMaxBytesFormatted } from '../../get_max_bytes'; +import { getMaxBytesFormatted } from '../../importer/get_max_bytes'; import { validateFile } from '../../importer'; import { GeoJsonImporter, diff --git a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx index d73c6e9c5fb3a..5863b18d0cea0 100644 --- a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx +++ b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx @@ -12,7 +12,7 @@ import { getIndexPatternService } from '../kibana_services'; import { GeoJsonUploadForm, OnFileSelectParameters } from './geojson_upload_form'; import { ImportCompleteView } from './import_complete_view'; import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/public'; -import { FileUploadComponentProps } from '../lazy_load_bundle'; +import type { FileUploadComponentProps, FileUploadGeoResults } from '../lazy_load_bundle'; import { ImportResults } from '../importer'; import { GeoJsonImporter } from '../importer/geojson_importer'; import { Settings } from '../../common'; @@ -93,7 +93,7 @@ export class JsonUploadAndParse extends Component + [ES_FIELD_TYPES.GEO_POINT as string, ES_FIELD_TYPES.GEO_SHAPE as string].includes( + field.type + ) + ); + if (!geoField) { + throw new Error('geo field not created in index pattern'); + } + results = { + indexPatternId: indexPattern.id, + geoFieldName: geoField.name, + geoFieldType: geoField.type as ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE, + docCount: importResults.docCount !== undefined ? importResults.docCount : 0, + }; } catch (error) { if (this._isMounted) { this.setState({ @@ -200,7 +218,7 @@ export class JsonUploadAndParse extends Component { this._geojsonImporter = importer; - this.props.onFileUpload( + this.props.onFileSelect( { type: 'FeatureCollection', features, @@ -245,7 +260,7 @@ export class JsonUploadAndParse extends Component { diff --git a/x-pack/plugins/file_upload/public/get_max_bytes.ts b/x-pack/plugins/file_upload/public/importer/get_max_bytes.ts similarity index 91% rename from x-pack/plugins/file_upload/public/get_max_bytes.ts rename to x-pack/plugins/file_upload/public/importer/get_max_bytes.ts index 2e002e65248c9..f1ca532692e77 100644 --- a/x-pack/plugins/file_upload/public/get_max_bytes.ts +++ b/x-pack/plugins/file_upload/public/importer/get_max_bytes.ts @@ -5,7 +5,6 @@ * 2.0. */ -// @ts-ignore import numeral from '@elastic/numeral'; import { MAX_FILE_SIZE, @@ -13,8 +12,8 @@ import { ABSOLUTE_MAX_FILE_SIZE_BYTES, FILE_SIZE_DISPLAY_FORMAT, UI_SETTING_MAX_FILE_SIZE, -} from '../common'; -import { getUiSettings } from './kibana_services'; +} from '../../common'; +import { getUiSettings } from '../kibana_services'; export function getMaxBytes() { const maxFileSize = getUiSettings().get(UI_SETTING_MAX_FILE_SIZE, MAX_FILE_SIZE); diff --git a/x-pack/plugins/file_upload/public/importer/importer.ts b/x-pack/plugins/file_upload/public/importer/importer.ts index 4a87d67d0616b..49324c8f360ef 100644 --- a/x-pack/plugins/file_upload/public/importer/importer.ts +++ b/x-pack/plugins/file_upload/public/importer/importer.ts @@ -260,7 +260,7 @@ export function callImportRoute({ }); return getHttp().fetch({ - path: `/api/file_upload/import`, + path: `/internal/file_upload/import`, method: 'POST', query, body, diff --git a/x-pack/plugins/file_upload/public/importer/validate_file.ts b/x-pack/plugins/file_upload/public/importer/validate_file.ts index 4c7fe704d8afa..60d93ad552d0d 100644 --- a/x-pack/plugins/file_upload/public/importer/validate_file.ts +++ b/x-pack/plugins/file_upload/public/importer/validate_file.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { getMaxBytes, getMaxBytesFormatted } from '../get_max_bytes'; +import { getMaxBytes, getMaxBytesFormatted } from './get_max_bytes'; export function validateFile(file: File, types: string[]) { if (file.size > getMaxBytes()) { diff --git a/x-pack/plugins/file_upload/public/index.ts b/x-pack/plugins/file_upload/public/index.ts index 0c81779130d87..792568e9c11ad 100644 --- a/x-pack/plugins/file_upload/public/index.ts +++ b/x-pack/plugins/file_upload/public/index.ts @@ -11,9 +11,7 @@ export function plugin() { return new FileUploadPlugin(); } -export * from '../common'; - export * from './importer/types'; export { FileUploadPluginStart } from './plugin'; -export { FileUploadComponentProps } from './lazy_load_bundle'; +export { FileUploadComponentProps, FileUploadGeoResults } from './lazy_load_bundle'; diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts index 807d2fae52bf8..9d89b6b761e25 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts @@ -7,21 +7,25 @@ import React from 'react'; import { FeatureCollection } from 'geojson'; -import { IndexPattern } from 'src/plugins/data/public'; import { HttpStart } from 'src/core/public'; -import { IImporter, ImportFactoryOptions, ImportResults } from '../importer'; +import { IImporter, ImportFactoryOptions } from '../importer'; import { getHttp } from '../kibana_services'; +import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/public'; + +export interface FileUploadGeoResults { + indexPatternId: string; + geoFieldName: string; + geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE; + docCount: number; +} export interface FileUploadComponentProps { isIndexingTriggered: boolean; - onFileUpload: (geojsonFile: FeatureCollection, name: string, previewCoverage: number) => void; - onFileRemove: () => void; + onFileSelect: (geojsonFile: FeatureCollection, name: string, previewCoverage: number) => void; + onFileClear: () => void; onIndexReady: (indexReady: boolean) => void; - onIndexingComplete: (results: { - indexDataResp: ImportResults; - indexPattern: IndexPattern; - }) => void; - onIndexingError: () => void; + onUploadComplete: (results: FileUploadGeoResults) => void; + onUploadError: () => void; } let loadModulesPromise: Promise; @@ -32,7 +36,7 @@ interface LazyLoadedFileUploadModules { getHttp: () => HttpStart; } -export async function lazyLoadFileUploadModules(): Promise { +export async function lazyLoadModules(): Promise { if (typeof loadModulesPromise !== 'undefined') { return loadModulesPromise; } diff --git a/x-pack/plugins/file_upload/public/plugin.ts b/x-pack/plugins/file_upload/public/plugin.ts index a4e386b85e182..19306fadfd61c 100644 --- a/x-pack/plugins/file_upload/public/plugin.ts +++ b/x-pack/plugins/file_upload/public/plugin.ts @@ -11,10 +11,13 @@ import { getFileUploadComponent, importerFactory, hasImportPermission, + checkIndexExists, + getTimeFieldRange, + analyzeFile, } from './api'; import { setStartServices } from './kibana_services'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; -import { getMaxBytes, getMaxBytesFormatted } from './get_max_bytes'; +import { getMaxBytes, getMaxBytesFormatted } from './importer/get_max_bytes'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface FileUploadSetupDependencies {} @@ -43,6 +46,9 @@ export class FileUploadPlugin getMaxBytes, getMaxBytesFormatted, hasImportPermission, + checkIndexExists, + getTimeFieldRange, + analyzeFile, }; } } diff --git a/x-pack/plugins/file_upload/server/get_time_field_range.ts b/x-pack/plugins/file_upload/server/get_time_field_range.ts new file mode 100644 index 0000000000000..66a428128cbe1 --- /dev/null +++ b/x-pack/plugins/file_upload/server/get_time_field_range.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { IScopedClusterClient } from 'kibana/server'; +export async function getTimeFieldRange( + client: IScopedClusterClient, + index: string[] | string, + timeFieldName: string, + query: any +): Promise<{ + success: boolean; + start: { epoch: number; string: string }; + end: { epoch: number; string: string }; +}> { + const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; + + const { + body: { aggregations }, + } = await client.asCurrentUser.search({ + index, + size: 0, + body: { + ...(query ? { query } : {}), + aggs: { + earliest: { + min: { + field: timeFieldName, + }, + }, + latest: { + max: { + field: timeFieldName, + }, + }, + }, + }, + }); + + if (aggregations && aggregations.earliest && aggregations.latest) { + // @ts-expect-error fix search aggregation response + obj.start.epoch = aggregations.earliest.value; + // @ts-expect-error fix search aggregation response + obj.start.string = aggregations.earliest.value_as_string; + + // @ts-expect-error fix search aggregation response + obj.end.epoch = aggregations.latest.value; + // @ts-expect-error fix search aggregation response + obj.end.string = aggregations.latest.value_as_string; + } + return obj; +} diff --git a/x-pack/plugins/file_upload/server/routes.ts b/x-pack/plugins/file_upload/server/routes.ts index 6d7eb77f39069..f2e796ec53ce0 100644 --- a/x-pack/plugins/file_upload/server/routes.ts +++ b/x-pack/plugins/file_upload/server/routes.ts @@ -16,11 +16,12 @@ import { Settings, } from '../common'; import { wrapError } from './error_wrapper'; -import { analyzeFile } from './analyze_file'; import { importDataProvider } from './import_data'; +import { getTimeFieldRange } from './get_time_field_range'; +import { analyzeFile } from './analyze_file'; import { updateTelemetry } from './telemetry'; -import { analyzeFileQuerySchema, importFileBodySchema, importFileQuerySchema } from './schemas'; +import { importFileBodySchema, importFileQuerySchema, analyzeFileQuerySchema } from './schemas'; import { CheckPrivilegesPayload } from '../../security/server'; import { StartDeps } from './types'; @@ -92,7 +93,7 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge /** * @apiGroup FileDataVisualizer * - * @api {post} /api/file_upload/analyze_file Analyze file data + * @api {post} /internal/file_upload/analyze_file Analyze file data * @apiName AnalyzeFile * @apiDescription Performs analysis of the file data. * @@ -100,7 +101,7 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge */ router.post( { - path: '/api/file_upload/analyze_file', + path: '/internal/file_data_visualizer/analyze_file', validate: { body: schema.any(), query: analyzeFileQuerySchema, @@ -130,7 +131,7 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge /** * @apiGroup FileDataVisualizer * - * @api {post} /api/file_upload/import Import file data + * @api {post} /internal/file_upload/import Import file data * @apiName ImportFile * @apiDescription Imports file data into elasticsearch index. * @@ -139,7 +140,7 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge */ router.post( { - path: '/api/file_upload/import', + path: '/internal/file_upload/import', validate: { query: importFileQuerySchema, body: importFileBodySchema, @@ -180,4 +181,90 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge } } ); + + /** + * @apiGroup FileDataVisualizer + * + * @api {post} /internal/file_upload/index_exists ES Field caps wrapper checks if index exists + * @apiName IndexExists + */ + router.post( + { + path: '/internal/file_upload/index_exists', + validate: { + body: schema.object({ index: schema.string() }), + }, + options: { + tags: ['access:fileUpload:analyzeFile'], + }, + }, + async (context, request, response) => { + try { + const { index } = request.body; + + const options = { + index: [index], + fields: ['*'], + ignore_unavailable: true, + allow_no_indices: true, + }; + + const { body } = await context.core.elasticsearch.client.asCurrentUser.fieldCaps(options); + const exists = Array.isArray(body.indices) && body.indices.length !== 0; + return response.ok({ + body: { exists }, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + } + ); + + /** + * @apiGroup FileDataVisualizer + * + * @api {post} /internal/file_upload/time_field_range Get time field range + * @apiName GetTimeFieldRange + * @apiDescription Returns the time range for the given index and query using the specified time range. + * + * @apiSchema (body) getTimeFieldRangeSchema + * + * @apiSuccess {Object} start start of time range with epoch and string properties. + * @apiSuccess {Object} end end of time range with epoch and string properties. + */ + router.post( + { + path: '/internal/file_upload/time_field_range', + validate: { + body: schema.object({ + /** Index or indexes for which to return the time range. */ + index: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + /** Name of the time field in the index. */ + timeFieldName: schema.string(), + /** Query to match documents in the index(es). */ + query: schema.maybe(schema.any()), + }), + }, + options: { + tags: ['access:fileUpload:analyzeFile'], + }, + }, + async (context, request, response) => { + try { + const { index, timeFieldName, query } = request.body; + const resp = await getTimeFieldRange( + context.core.elasticsearch.client, + index, + timeFieldName, + query + ); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + } + ); } diff --git a/x-pack/plugins/file_upload/tsconfig.json b/x-pack/plugins/file_upload/tsconfig.json index 887a05af31174..3e146d76fbb90 100644 --- a/x-pack/plugins/file_upload/tsconfig.json +++ b/x-pack/plugins/file_upload/tsconfig.json @@ -13,5 +13,6 @@ { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../security/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, ] } diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index faa1127cfe1da..7bf3c3e6205ec 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -15,6 +15,7 @@ export const requiredPackages = { System: 'system', Endpoint: 'endpoint', ElasticAgent: 'elastic_agent', + SecurityDetectionEngine: 'security_detection_engine', } as const; // these are currently identical. we can separate if they later diverge diff --git a/x-pack/plugins/fleet/common/constants/preconfiguration.ts b/x-pack/plugins/fleet/common/constants/preconfiguration.ts index 376ba551b1359..da011f31783c3 100644 --- a/x-pack/plugins/fleet/common/constants/preconfiguration.ts +++ b/x-pack/plugins/fleet/common/constants/preconfiguration.ts @@ -7,3 +7,5 @@ export const PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE = 'fleet-preconfiguration-deletion-record'; + +export const PRECONFIGURATION_LATEST_KEYWORD = 'latest'; diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 9c2947b836b9b..377cb8d8bd871 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -76,6 +76,7 @@ export const SETTINGS_API_ROUTES = { // App API routes export const APP_API_ROUTES = { CHECK_PERMISSIONS_PATTERN: `${API_ROOT}/check-permissions`, + GENERATE_SERVICE_TOKEN_PATTERN: `${API_ROOT}/service-tokens`, }; // Agent API routes @@ -97,11 +98,6 @@ export const AGENT_API_ROUTES = { UPGRADE_PATTERN: `${API_ROOT}/agents/{agentId}/upgrade`, BULK_UPGRADE_PATTERN: `${API_ROOT}/agents/bulk_upgrade`, }; -export const AGENT_API_ROUTES_7_9 = { - CHECKIN_PATTERN: `${FLEET_API_ROOT_7_9}/agents/{agentId}/checkin`, - ACKS_PATTERN: `${FLEET_API_ROOT_7_9}/agents/{agentId}/acks`, - ENROLL_PATTERN: `${FLEET_API_ROOT_7_9}/agents/enroll`, -}; export const ENROLLMENT_API_KEY_ROUTES = { CREATE_PATTERN: `${API_ROOT}/enrollment-api-keys`, diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@enroll.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@enroll.yaml deleted file mode 100644 index 1946a65e33fdc..0000000000000 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@enroll.yaml +++ /dev/null @@ -1,48 +0,0 @@ -post: - summary: Fleet - Agent - Enroll - tags: [] - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - action: - type: string - item: - $ref: ../components/schemas/agent.yaml - operationId: post-fleet-agents-enroll - parameters: - - $ref: ../components/headers/kbn_xsrf.yaml - requestBody: - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - PERMANENT - - EPHEMERAL - - TEMPORARY - shared_id: - type: string - deprecated: true - metadata: - type: object - required: - - local - - user_provided - properties: - local: - $ref: ../components/schemas/agent_metadata.yaml - user_provided: - $ref: ../components/schemas/agent_metadata.yaml - required: - - type - - metadata - security: - - Enrollment API Key: [] diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@acks.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@acks.yaml deleted file mode 100644 index 6728554bf542e..0000000000000 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@acks.yaml +++ /dev/null @@ -1,32 +0,0 @@ -parameters: - - schema: - type: string - name: agentId - in: path - required: true -post: - summary: Fleet - Agent - Acks - tags: [] - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - action: - type: string - enum: - - acks - required: - - action - operationId: post-fleet-agents-agentId-acks - parameters: - - $ref: ../components/headers/kbn_xsrf.yaml - requestBody: - content: - application/json: - schema: - type: object - properties: {} diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@checkin.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@checkin.yaml deleted file mode 100644 index cc797c7356603..0000000000000 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@checkin.yaml +++ /dev/null @@ -1,60 +0,0 @@ -parameters: - - schema: - type: string - name: agentId - in: path - required: true -post: - summary: Fleet - Agent - Check In - tags: [] - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - action: - type: string - enum: - - checkin - actions: - type: array - items: - type: object - properties: - agent_id: - type: string - data: - type: object - id: - type: string - created_at: - type: string - format: date-time - type: - type: string - required: - - agent_id - - data - - id - - created_at - - type - operationId: post-fleet-agents-agentId-checkin - parameters: - - $ref: ../components/headers/kbn_xsrf.yaml - security: - - Access API Key: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - local_metadata: - $ref: ../components/schemas/agent_metadata.yaml - events: - type: array - items: - $ref: ../components/schemas/new_agent_event.yaml diff --git a/x-pack/plugins/fleet/common/services/full_agent_policy_kibana_config.test.ts b/x-pack/plugins/fleet/common/services/full_agent_policy_kibana_config.test.ts deleted file mode 100644 index 07e728b928c48..0000000000000 --- a/x-pack/plugins/fleet/common/services/full_agent_policy_kibana_config.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getFullAgentPolicyKibanaConfig } from './full_agent_policy_kibana_config'; - -describe('Fleet - getFullAgentPolicyKibanaConfig', () => { - it('should return no path when there is no path', () => { - expect(getFullAgentPolicyKibanaConfig(['http://localhost:5601'])).toEqual({ - hosts: ['localhost:5601'], - protocol: 'http', - }); - }); - it('should return correct config when there is a path', () => { - expect(getFullAgentPolicyKibanaConfig(['http://localhost:5601/ssg'])).toEqual({ - hosts: ['localhost:5601'], - protocol: 'http', - path: '/ssg/', - }); - }); - it('should return correct config when there is a path that ends in a slash', () => { - expect(getFullAgentPolicyKibanaConfig(['http://localhost:5601/ssg/'])).toEqual({ - hosts: ['localhost:5601'], - protocol: 'http', - path: '/ssg/', - }); - }); - it('should return correct config when there are multiple hosts', () => { - expect( - getFullAgentPolicyKibanaConfig(['http://localhost:5601/ssg/', 'http://localhost:3333/ssg/']) - ).toEqual({ - hosts: ['localhost:5601', 'localhost:3333'], - protocol: 'http', - path: '/ssg/', - }); - }); -}); diff --git a/x-pack/plugins/fleet/common/services/full_agent_policy_kibana_config.ts b/x-pack/plugins/fleet/common/services/full_agent_policy_kibana_config.ts deleted file mode 100644 index 6b2709cc1961d..0000000000000 --- a/x-pack/plugins/fleet/common/services/full_agent_policy_kibana_config.ts +++ /dev/null @@ -1,27 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FullAgentPolicyKibanaConfig } from '../types'; - -export function getFullAgentPolicyKibanaConfig(kibanaUrls: string[]): FullAgentPolicyKibanaConfig { - // paths and protocol are validated to be the same for all urls, so use the first to get them - const firstUrlParsed = new URL(kibanaUrls[0]); - const config: FullAgentPolicyKibanaConfig = { - // remove the : from http: - protocol: firstUrlParsed.protocol.replace(':', ''), - hosts: kibanaUrls.map((url) => new URL(url).host), - }; - - // add path if user provided one - if (firstUrlParsed.pathname !== '/') { - // make sure the path ends with / - config.path = firstUrlParsed.pathname.endsWith('/') - ? firstUrlParsed.pathname - : `${firstUrlParsed.pathname}/`; - } - return config; -} diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index e1b3791d9cbb5..6156decf8641d 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -164,6 +164,7 @@ export const settingsRoutesService = { export const appRoutesService = { getCheckPermissionsPath: () => APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN, + getRegenerateServiceTokenPath: () => APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN, }; export const enrollmentAPIKeyRouteService = { diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index cdea56448f3a2..03584a48ff17c 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -15,7 +15,6 @@ export interface FleetConfigType { registryProxyUrl?: string; agents: { enabled: boolean; - fleetServerEnabled: boolean; tlsCheckDisabled: boolean; pollingRequestTimeout: number; maxConcurrentConnections: number; diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 3bc0d97d64646..eab13fe5819f9 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -30,7 +30,12 @@ export enum InstallStatus { uninstalling = 'uninstalling', } -export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'install'; +export interface DefaultPackagesInstallationError { + installType: InstallType; + error: Error; +} + +export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'install' | 'unknown'; export type InstallSource = 'registry' | 'upload'; export type EpmPackageInstallStatus = 'installed' | 'installing'; diff --git a/x-pack/plugins/fleet/common/types/models/preconfiguration.ts b/x-pack/plugins/fleet/common/types/models/preconfiguration.ts index c9fff1c1581bd..61a5cb63400a0 100644 --- a/x-pack/plugins/fleet/common/types/models/preconfiguration.ts +++ b/x-pack/plugins/fleet/common/types/models/preconfiguration.ts @@ -28,6 +28,4 @@ export interface PreconfiguredAgentPolicy extends Omit; } -export interface PreconfiguredPackage extends Omit { - force?: boolean; -} +export type PreconfiguredPackage = Omit; diff --git a/x-pack/plugins/fleet/common/types/models/settings.ts b/x-pack/plugins/fleet/common/types/models/settings.ts index d6932f9a4d83f..15d7492868797 100644 --- a/x-pack/plugins/fleet/common/types/models/settings.ts +++ b/x-pack/plugins/fleet/common/types/models/settings.ts @@ -9,10 +9,8 @@ import type { SavedObjectAttributes } from 'src/core/public'; export interface BaseSettings { has_seen_add_data_notice?: boolean; + has_seen_fleet_migration_notice?: boolean; fleet_server_hosts: string[]; - // TODO remove as part of https://github.com/elastic/kibana/issues/94303 - kibana_urls: string[]; - kibana_ca_sha256?: string; } export interface Settings extends BaseSettings { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 4616e92925b3a..47f9112d4ab59 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -108,6 +108,7 @@ export interface PostAgentUnenrollRequest { }; body: { force?: boolean; + revoke?: boolean; }; } @@ -118,6 +119,7 @@ export interface PostBulkAgentUnenrollRequest { body: { agents: string[] | string; force?: boolean; + revoke?: boolean; }; } diff --git a/x-pack/plugins/fleet/common/types/rest_spec/app.ts b/x-pack/plugins/fleet/common/types/rest_spec/app.ts index 3e54cf04d7533..a742c387c14aa 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/app.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/app.ts @@ -9,3 +9,8 @@ export interface CheckPermissionsResponse { error?: 'MISSING_SECURITY' | 'MISSING_SUPERUSER_ROLE'; success: boolean; } + +export interface GenerateServiceTokenResponse { + name: string; + value: string; +} diff --git a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts index 3c7a32265d20a..e5c7ace420c73 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts @@ -12,6 +12,7 @@ import type { RegistrySearchResult, PackageInfo, PackageUsageStats, + InstallType, } from '../models/epm'; export interface GetCategoriesRequest { @@ -83,8 +84,10 @@ export interface IBulkInstallPackageHTTPError { } export interface InstallResult { - assets: AssetReference[]; - status: 'installed' | 'already_installed'; + assets?: AssetReference[]; + status?: 'installed' | 'already_installed'; + error?: Error; + installType: InstallType; } export interface BulkInstallPackageInfo { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts b/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts index 2180b66908498..6f64f1c48336d 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts @@ -5,7 +5,10 @@ * 2.0. */ +import type { DefaultPackagesInstallationError } from '../models/epm'; + export interface PostIngestSetupResponse { isInitialized: boolean; preconfigurationError?: { name: string; message: string }; + nonFatalPackageUpgradeErrors?: DefaultPackagesInstallationError[]; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index 5663bd4768d5c..f2eee6228906a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -90,6 +90,13 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { }), }); } + if (setupResponse.data.nonFatalPackageUpgradeErrors) { + notifications.toasts.addError(setupResponse.data.nonFatalPackageUpgradeErrors, { + title: i18n.translate('xpack.fleet.setup.nonFatalPackageErrorsTitle', { + defaultMessage: 'One or more packages could not be successfully upgraded', + }), + }); + } } catch (err) { setInitializationError(err); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx index 071a9dc5943b2..41ef63b23b564 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx @@ -5,43 +5,54 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiLink, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import type { CSSProperties } from 'react'; import React, { memo } from 'react'; -import type { EuiLinkProps } from '@elastic/eui/src/components/link/link'; +import type { AgentPolicy } from '../../../../common/types'; +import { useLink } from '../hooks'; const MIN_WIDTH: CSSProperties = { minWidth: 0 }; const NO_WRAP_WHITE_SPACE: CSSProperties = { whiteSpace: 'nowrap' }; -export type LinkAndRevisionProps = EuiLinkProps & { - revision?: string | number; -}; - -/** - * Components shows a link for a given value along with a revision number to its right. The display - * value is truncated if it is longer than the width of where it is displayed, while the revision - * always remain visible - */ -export const LinkAndRevision = memo( - ({ revision, className, ...euiLinkProps }) => { - return ( - - - +export const AgentPolicySummaryLine = memo<{ policy: AgentPolicy }>(({ policy }) => { + const { getHref } = useLink(); + const { name, id, revision, is_managed: isManaged } = policy; + return ( + + + + {name || id} + + + {isManaged && ( + + )} + {revision && ( + + + + - {revision && ( - - - - - - )} - - ); - } -); + )} + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx index cb0b02527f756..e7fd1da394bb3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx @@ -14,7 +14,18 @@ import { usePackageIconType } from '../hooks'; export const PackageIcon: React.FunctionComponent< UsePackageIconType & Omit -> = ({ packageName, version, icons, tryApi, ...euiIconProps }) => { +> = ({ size = 's', packageName, version, icons, tryApi, ...euiIconProps }) => { const iconType = usePackageIconType({ packageName, version, icons, tryApi }); - return ; + return ( + + // this collides with some EuiText (+img) CSS from the EuiIcon component + // which makes the button large, wide, and poorly layed out + // override those styles until the bug is fixed or we find a better approach + style={{ margin: 'unset', width: 'unset' }} + size={size} + type={iconType} + {...euiIconProps} + /> + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx index 8bef32916452f..ae9863e84d605 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx @@ -113,7 +113,7 @@ export const SettingsConfirmModal = React.memo( title={ } color="warning" @@ -124,13 +124,13 @@ export const SettingsConfirmModal = React.memo(

), @@ -143,13 +143,13 @@ export const SettingsConfirmModal = React.memo(

), @@ -178,7 +178,7 @@ export const SettingsConfirmModal = React.memo( diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx index 30e1aedc3e5a5..f3c353fd75dba 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx @@ -251,19 +251,10 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { const body = settings && ( - -

- -

-
- outputs, }} @@ -279,7 +270,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { helpText={ = ({ onClose }) => { defaultMessage: 'Elasticsearch hosts', })} helpText={i18n.translate('xpack.fleet.settings.elasticsearchUrlsHelpTect', { - defaultMessage: 'Specify the Elasticsearch URLs where agents will send data.', + defaultMessage: 'Specify the Elasticsearch URLs where agents send data.', })} {...inputs.elasticsearchUrl.formRowProps} > diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_link.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_link.ts index 440cd693d7af2..5c31f6fc4158e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_link.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_link.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { BASE_PATH, pagePathGetters } from '../constants'; +import { BASE_PATH, pagePathGetters, PLUGIN_ID } from '../constants'; import type { StaticPage, DynamicPage, DynamicPagePathValues } from '../constants'; import { useStartServices } from './'; @@ -18,6 +18,8 @@ export const useLink = () => { const core = useStartServices(); return { getPath, + getAssetsPath: (path: string) => + core.http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/${path}`), getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => { const path = getPath(page, values); return core.http.basePath.prepend(`${BASE_PATH}#${path}`); diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/app.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/app.ts index bd690a4b53e07..c84dd0fd15b44 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/app.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/app.ts @@ -6,7 +6,7 @@ */ import { appRoutesService } from '../../services'; -import type { CheckPermissionsResponse } from '../../types'; +import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../types'; import { sendRequest } from './use_request'; @@ -16,3 +16,10 @@ export const sendGetPermissionsCheck = () => { method: 'get', }); }; + +export const sendGenerateServiceToken = () => { + return sendRequest({ + path: appRoutesService.getRegenerateServiceTokenPath(), + method: 'post', + }); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts index 81ef6a6703c34..5d53425607361 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts @@ -14,7 +14,6 @@ export const createConfigurationMock = (): FleetConfigType => { registryProxyUrl: '', agents: { enabled: true, - fleetServerEnabled: false, tlsCheckDisabled: true, pollingRequestTimeout: 1000, maxConcurrentConnections: 100, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx index 53fab8bda4a8c..26d47cbff5b86 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx @@ -94,7 +94,11 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ sortOrder: 'asc', full: true, }); - const agentPolicies = useMemo(() => agentPoliciesData?.items || [], [agentPoliciesData?.items]); + const agentPolicies = useMemo( + () => agentPoliciesData?.items.filter((policy) => !policy.is_managed) || [], + [agentPoliciesData?.items] + ); + const agentPoliciesById = useMemo(() => { return agentPolicies.reduce((acc: { [key: string]: GetAgentPoliciesResponseItem }, policy) => { acc[policy.id] = policy; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx index 3e6ca5944c380..65cf62a279a22 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx @@ -106,9 +106,9 @@ export const AgentPolicyDetailsPage: React.FunctionComponent = () => { {agentPolicy?.is_managed && ( ( export const AgentPolicyListPage: React.FunctionComponent<{}> = () => { useBreadcrumbs('policies_list'); - const { getHref, getPath } = useLink(); + const { getPath } = useLink(); const hasWriteCapabilites = useCapabilities().write; const { agents: { enabled: isFleetEnabled }, @@ -132,13 +132,7 @@ export const AgentPolicyListPage: React.FunctionComponent<{}> = () => { }), width: '20%', render: (name: string, agentPolicy: AgentPolicy) => ( - - {name || agentPolicy.id} - + ), }, { @@ -205,7 +199,7 @@ export const AgentPolicyListPage: React.FunctionComponent<{}> = () => { } return cols; - }, [getHref, isFleetEnabled, resendRequest]); + }, [isFleetEnabled, resendRequest]); const createAgentPolicyButton = useMemo( () => ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx index e5ad131bd7e05..d814d2d0646a8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx @@ -14,7 +14,7 @@ import { useCapabilities, useKibanaVersion } from '../../../../hooks'; import { ContextMenuActions } from '../../../../components'; import { AgentUnenrollAgentModal, - AgentReassignAgentPolicyFlyout, + AgentReassignAgentPolicyModal, AgentUpgradeAgentModal, } from '../../components'; import { useAgentRefresh } from '../hooks'; @@ -45,7 +45,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ <> {isReassignFlyoutOpen && ( - + )} {isUnenrollModalOpen && ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx index 482861b3db9e8..bf8385712dd23 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx @@ -21,10 +21,10 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import type { Agent, AgentPolicy } from '../../../../../types'; -import { useKibanaVersion, useLink } from '../../../../../hooks'; +import { useKibanaVersion } from '../../../../../hooks'; import { isAgentUpgradeable } from '../../../../../services'; import { AgentPolicyPackageBadges } from '../../../components/agent_policy_package_badges'; -import { LinkAndRevision } from '../../../../../components'; +import { AgentPolicySummaryLine } from '../../../../../components'; // Allows child text to be truncated const FlexItemWithMinWidth = styled(EuiFlexItem)` @@ -35,7 +35,6 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{ agent: Agent; agentPolicy?: AgentPolicy; }> = memo(({ agent, agentPolicy }) => { - const { getHref } = useLink(); const kibanaVersion = useKibanaVersion(); return ( @@ -52,13 +51,7 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{ defaultMessage: 'Agent policy', }), description: agentPolicy ? ( - - {agentPolicy.name || agentPolicy.id} - + ) : ( agent.policy_id || '-' ), diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx index de27d5fada755..b59ef1f749fb2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx @@ -22,7 +22,7 @@ import { FormattedMessage, FormattedNumber } from '@kbn/i18n/react'; import { SO_SEARCH_LIMIT } from '../../../../constants'; import type { Agent } from '../../../../types'; import { - AgentReassignAgentPolicyFlyout, + AgentReassignAgentPolicyModal, AgentUnenrollAgentModal, AgentUpgradeAgentModal, } from '../../components'; @@ -161,7 +161,7 @@ export const AgentBulkActions: React.FunctionComponent<{ <> {isReassignFlyoutOpen && ( - { setIsReassignFlyoutOpen(false); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index d01d290e129b8..88249f7f5d5ce 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -37,11 +37,11 @@ import { useKibanaVersion, useStartServices, } from '../../../hooks'; -import { ContextMenuActions } from '../../../components'; +import { AgentPolicySummaryLine, ContextMenuActions } from '../../../components'; import { AgentStatusKueryHelper, isAgentUpgradeable } from '../../../services'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { - AgentReassignAgentPolicyFlyout, + AgentReassignAgentPolicyModal, AgentHealth, AgentUnenrollAgentModal, AgentUpgradeAgentModal, @@ -344,8 +344,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { if (!agent.policy_id) return true; const agentPolicy = agentPoliciesIndexedById[agent.policy_id]; - const isManaged = agentPolicy?.is_managed === true; - return !isManaged; + const isHosted = agentPolicy?.is_managed === true; + return !isHosted; }; const columns = [ @@ -374,48 +374,24 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { defaultMessage: 'Agent policy', }), render: (policyId: string, agent: Agent) => { - const policyName = agentPoliciesIndexedById[policyId]?.name; + const agentPolicy = agentPoliciesIndexedById[policyId]; + const showWarning = agent.policy_revision && agentPolicy?.revision > agent.policy_revision; + return ( - - - {policyName || policyId} - - - {agent.policy_revision && ( + {agentPolicy && } + {showWarning && ( - + + +   )} - {agent.policy_id && - agent.policy_revision && - agentPoliciesIndexedById[agent.policy_id] && - agentPoliciesIndexedById[agent.policy_id].revision > agent.policy_revision && ( - - - -   - {true && ( - <> - - - )} - - - )} ); }, @@ -431,7 +407,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { {safeMetadata(version)} - {isAgentUpgradeable(agent, kibanaVersion) ? ( + {isAgentSelectable(agent) && isAgentUpgradeable(agent, kibanaVersion) ? ( @@ -512,7 +488,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { ) : null} {agentToReassign && ( - { setAgentToReassign(undefined); @@ -617,7 +593,15 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { emptyPrompt ) } - items={totalAgents ? agents : []} + items={ + totalAgents + ? showUpgradeable + ? agents.filter( + (agent) => isAgentSelectable(agent) && isAgentUpgradeable(agent, kibanaVersion) + ) + : agents + : [] + } itemId="id" columns={columns} pagination={{ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx index e5f3cdbcfba97..2e37d9efc7857 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { EuiButton, EuiFlexGroup, @@ -16,62 +16,283 @@ import { EuiText, EuiLink, EuiEmptyPrompt, + EuiSteps, + EuiCodeBlock, + EuiCallOut, + EuiSelect, } from '@elastic/eui'; +import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; import styled from 'styled-components'; -import { FormattedMessage } from 'react-intl'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; -import { useStartServices } from '../../../hooks'; +import { DownloadStep } from '../components/agent_enrollment_flyout/steps'; +import { useStartServices, useGetOutputs, sendGenerateServiceToken } from '../../../hooks'; + +const FlexItemWithMinWidth = styled(EuiFlexItem)` + min-width: 0px; + max-width: 100%; +`; export const ContentWrapper = styled(EuiFlexGroup)` height: 100%; + margin: 0 auto; + max-width: 800px; `; -function renderOnPremInstructions() { +// Otherwise the copy button is over the text +const CommandCode = styled.pre({ + overflow: 'scroll', +}); + +type PLATFORM_TYPE = 'linux-mac' | 'windows' | 'rpm-deb'; +const PLATFORM_OPTIONS: Array<{ text: string; value: PLATFORM_TYPE }> = [ + { text: 'Linux / macOS', value: 'linux-mac' }, + { text: 'Windows', value: 'windows' }, + { text: 'RPM / DEB', value: 'rpm-deb' }, +]; + +export const ServiceTokenStep = ({ + serviceToken, + getServiceToken, + isLoadingServiceToken, +}: { + serviceToken?: string; + getServiceToken: () => void; + isLoadingServiceToken: boolean; +}): EuiStepProps => { + return { + title: i18n.translate('xpack.fleet.fleetServerSetup.stepGenerateServiceTokenTitle', { + defaultMessage: 'Generate a service token', + }), + children: ( + <> + + + + + {!serviceToken ? ( + + + { + getServiceToken(); + }} + > + + + + + ) : ( + <> + + + + + + + + + + + + + {serviceToken} + + + + + )} + + ), + }; +}; + +export const FleetServerCommandStep = ({ + serviceToken, + installCommand, + platform, + setPlatform, +}: { + serviceToken?: string; + installCommand: string; + platform: string; + setPlatform: (platform: PLATFORM_TYPE) => void; +}): EuiStepProps => { + return { + title: i18n.translate('xpack.fleet.fleetServerSetup.stepInstallAgentTitle', { + defaultMessage: 'Start Fleet Server', + }), + status: !serviceToken ? 'disabled' : undefined, + children: serviceToken ? ( + <> + + + + + ), + }} + /> + + + + + + } + options={PLATFORM_OPTIONS} + value={platform} + onChange={(e) => setPlatform(e.target.value as PLATFORM_TYPE)} + aria-label={i18n.translate('xpack.fleet.fleetServerSetup.platformSelectAriaLabel', { + defaultMessage: 'Platform', + })} + /> + + + {installCommand} + + + ) : null, + }; +}; + +export const useFleetServerInstructions = () => { + const outputsRequest = useGetOutputs(); + const { notifications } = useStartServices(); + const [serviceToken, setServiceToken] = useState(); + const [isLoadingServiceToken, setIsLoadingServiceToken] = useState(false); + const [platform, setPlatform] = useState('linux-mac'); + + const output = outputsRequest.data?.items?.[0]; + const esHost = output?.hosts?.[0]; + + const installCommand = useMemo((): string => { + if (!serviceToken || !esHost) { + return ''; + } + switch (platform) { + case 'linux-mac': + return `sudo ./elastic-agent install -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; + case 'windows': + return `.\\elastic-agent.exe install --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; + case 'rpm-deb': + return `sudo elastic-agent enroll -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; + default: + return ''; + } + }, [serviceToken, esHost, platform]); + + const getServiceToken = useCallback(async () => { + setIsLoadingServiceToken(true); + try { + const { data } = await sendGenerateServiceToken(); + if (data?.value) { + setServiceToken(data?.value); + } + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.fleetServerSetup.errorGeneratingTokenTitleText', { + defaultMessage: 'Error generating token', + }), + }); + } + + setIsLoadingServiceToken(false); + }, [notifications]); + + return { + serviceToken, + getServiceToken, + isLoadingServiceToken, + installCommand, + platform, + setPlatform, + }; +}; + +const OnPremInstructions: React.FC = () => { + const { + serviceToken, + getServiceToken, + isLoadingServiceToken, + installCommand, + platform, + setPlatform, + } = useFleetServerInstructions(); + return ( - - - -

- } - body={ + + + +

- } - actions={ - - - - } +

+ + + + + ), + }} + /> +
+ +
); -} +}; -function renderCloudInstructions(deploymentUrl: string) { +const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploymentUrl }) => { return ( ); -} +}; export const FleetServerRequirementPage = () => { const startService = useStartServices(); @@ -134,11 +355,16 @@ export const FleetServerRequirementPage = () => { return ( <> - + + + {deploymentUrl ? ( + + ) : ( + + )} + - {deploymentUrl ? renderCloudInstructions(deploymentUrl) : renderOnPremInstructions()} - - + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/index.tsx index 9993014f55cdb..9e6505ede4918 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/index.tsx @@ -6,4 +6,9 @@ */ export { MissingESRequirementsPage } from './es_requirements_page'; -export { FleetServerRequirementPage } from './fleet_server_requirement_page'; +export { + FleetServerRequirementPage, + ServiceTokenStep, + FleetServerCommandStep, + useFleetServerInstructions, +} from './fleet_server_requirement_page'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx index d3c6ec114ee0a..0ad1706e5273f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx @@ -129,12 +129,12 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ ) : undefined } > - {fleetServerHosts.length === 0 ? null : mode === 'managed' ? ( + {fleetServerHosts.length === 0 && mode === 'managed' ? null : mode === 'managed' ? ( ) : ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx index 34b3536ac2810..8f6a2a26a2f6f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { EuiSteps, EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; import type { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; import { i18n } from '@kbn/i18n'; @@ -19,7 +19,12 @@ import { useFleetStatus, } from '../../../../hooks'; import { ManualInstructions } from '../../../../components/enrollment_instructions'; -import { FleetServerRequirementPage } from '../../agent_requirements_page'; +import { + FleetServerRequirementPage, + ServiceTokenStep, + FleetServerCommandStep, + useFleetServerInstructions, +} from '../../agent_requirements_page'; import { DownloadStep, AgentPolicySelectionStep } from './steps'; @@ -58,23 +63,55 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { const fleetStatus = useFleetStatus(); const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); const settings = useGetSettings(); - const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; + const fleetServerInstructions = useFleetServerInstructions(); - const steps: EuiContainedStepProps[] = [ - DownloadStep(), - AgentPolicySelectionStep({ agentPolicies, setSelectedAPIKeyId }), - { - title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { - defaultMessage: 'Enroll and start the Elastic Agent', + const steps = useMemo(() => { + const { + serviceToken, + getServiceToken, + isLoadingServiceToken, + installCommand, + platform, + setPlatform, + } = fleetServerInstructions; + const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; + const baseSteps: EuiContainedStepProps[] = [ + DownloadStep(), + AgentPolicySelectionStep({ + agentPolicies, + setSelectedAPIKeyId, + setIsFleetServerPolicySelected, }), - children: apiKey.data && ( - - ), - }, - ]; + ]; + if (isFleetServerPolicySelected) { + baseSteps.push( + ...[ + ServiceTokenStep({ serviceToken, getServiceToken, isLoadingServiceToken }), + FleetServerCommandStep({ serviceToken, installCommand, platform, setPlatform }), + ] + ); + } else { + baseSteps.push({ + title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { + defaultMessage: 'Enroll and start the Elastic Agent', + }), + children: apiKey.data && ( + + ), + }); + } + return baseSteps; + }, [ + agentPolicies, + apiKey.data, + isFleetServerPolicySelected, + settings.data?.item?.fleet_server_hosts, + fleetServerInstructions, + ]); return ( <> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx index 1d5e8cbfc5cd6..08b1cbdb341d5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx @@ -5,12 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiText, EuiButton, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import type { AgentPolicy } from '../../../../types'; +import type { AgentPolicy, PackagePolicy } from '../../../../types'; +import { sendGetOneAgentPolicy } from '../../../../hooks'; +import { FLEET_SERVER_PACKAGE } from '../../../../constants'; import { EnrollmentStepAgentPolicy } from './agent_policy_selection'; @@ -48,21 +50,49 @@ export const AgentPolicySelectionStep = ({ agentPolicies, setSelectedAPIKeyId, setSelectedPolicyId, + setIsFleetServerPolicySelected, }: { agentPolicies?: AgentPolicy[]; setSelectedAPIKeyId?: (key: string) => void; setSelectedPolicyId?: (policyId: string) => void; + setIsFleetServerPolicySelected?: (selected: boolean) => void; }) => { + const regularAgentPolicies = Array.isArray(agentPolicies) + ? agentPolicies.filter((policy) => policy && !policy.is_managed) + : []; + + const onAgentPolicyChange = useCallback( + async (policyId: string) => { + if (setSelectedPolicyId) { + setSelectedPolicyId(policyId); + } + if (setIsFleetServerPolicySelected) { + const agentPolicyRequest = await sendGetOneAgentPolicy(policyId); + if ( + agentPolicyRequest.data?.item && + (agentPolicyRequest.data.item.package_policies as PackagePolicy[]).some( + (packagePolicy) => packagePolicy.package?.name === FLEET_SERVER_PACKAGE + ) + ) { + setIsFleetServerPolicySelected(true); + } else { + setIsFleetServerPolicySelected(false); + } + } + }, + [setIsFleetServerPolicySelected, setSelectedPolicyId] + ); + return { title: i18n.translate('xpack.fleet.agentEnrollment.stepChooseAgentPolicyTitle', { defaultMessage: 'Choose an agent policy', }), children: ( ), }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_flyout/index.tsx deleted file mode 100644 index a23b27cf888d6..0000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_flyout/index.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiSpacer, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, - EuiFlyoutFooter, - EuiSelect, - EuiFormRow, - EuiText, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import type { Agent } from '../../../../types'; -import { - sendPutAgentReassign, - sendPostBulkAgentReassign, - useStartServices, - useGetAgentPolicies, -} from '../../../../hooks'; -import { AgentPolicyPackageBadges } from '../agent_policy_package_badges'; - -interface Props { - onClose: () => void; - agents: Agent[] | string; -} - -export const AgentReassignAgentPolicyFlyout: React.FunctionComponent = ({ - onClose, - agents, -}) => { - const { notifications } = useStartServices(); - const isSingleAgent = Array.isArray(agents) && agents.length === 1; - - const [selectedAgentPolicyId, setSelectedAgentPolicyId] = useState( - isSingleAgent ? (agents[0] as Agent).policy_id : undefined - ); - const agentPoliciesRequest = useGetAgentPolicies({ - page: 1, - perPage: 1000, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - const agentPolicies = agentPoliciesRequest.data ? agentPoliciesRequest.data.items : []; - useEffect(() => { - if (!selectedAgentPolicyId && agentPolicies[0]) { - setSelectedAgentPolicyId(agentPolicies[0].id); - } - }, [agentPolicies, selectedAgentPolicyId]); - - const [isSubmitting, setIsSubmitting] = useState(false); - async function onSubmit() { - try { - setIsSubmitting(true); - if (!selectedAgentPolicyId) { - throw new Error('No selected agent policy id'); - } - const res = isSingleAgent - ? await sendPutAgentReassign((agents[0] as Agent).id, { - policy_id: selectedAgentPolicyId, - }) - : await sendPostBulkAgentReassign({ - policy_id: selectedAgentPolicyId, - agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, - }); - if (res.error) { - throw res.error; - } - setIsSubmitting(false); - const successMessage = i18n.translate( - 'xpack.fleet.agentReassignPolicy.successSingleNotificationTitle', - { - defaultMessage: 'Agent policy reassigned', - } - ); - notifications.toasts.addSuccess(successMessage); - onClose(); - } catch (error) { - setIsSubmitting(false); - notifications.toasts.addError(error, { - title: 'Unable to reassign agent policy', - }); - } - } - - return ( - - - -

- -

-
- - - - -
- - - - - ({ - value: agentPolicy.id, - text: agentPolicy.name, - }))} - value={selectedAgentPolicyId} - onChange={(e) => setSelectedAgentPolicyId(e.target.value)} - /> - - - - - - {selectedAgentPolicyId && ( - - )} - - - - - - - - - - - - - - - -
- ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_modal/index.tsx new file mode 100644 index 0000000000000..8ee401d3c4ddf --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_modal/index.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiConfirmModal, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiFormRow, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import type { Agent } from '../../../../types'; +import { + sendPutAgentReassign, + sendPostBulkAgentReassign, + useStartServices, + useGetAgentPolicies, +} from '../../../../hooks'; +import { AgentPolicyPackageBadges } from '../agent_policy_package_badges'; + +interface Props { + onClose: () => void; + agents: Agent[] | string; +} + +export const AgentReassignAgentPolicyModal: React.FunctionComponent = ({ + onClose, + agents, +}) => { + const { notifications } = useStartServices(); + const isSingleAgent = Array.isArray(agents) && agents.length === 1; + + const [selectedAgentPolicyId, setSelectedAgentPolicyId] = useState( + isSingleAgent ? (agents[0] as Agent).policy_id : undefined + ); + const agentPoliciesRequest = useGetAgentPolicies({ + page: 1, + perPage: 1000, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + const agentPolicies = agentPoliciesRequest.data ? agentPoliciesRequest.data.items : []; + useEffect(() => { + if (!selectedAgentPolicyId && agentPolicies[0]) { + setSelectedAgentPolicyId(agentPolicies[0].id); + } + }, [agentPolicies, selectedAgentPolicyId]); + + const policySelectOptions = useMemo(() => { + return agentPolicies + .filter((policy) => policy && !policy.is_managed) + .map((agentPolicy) => ({ + value: agentPolicy.id, + text: agentPolicy.name, + })); + }, [agentPolicies]); + + const [isSubmitting, setIsSubmitting] = useState(false); + async function onSubmit() { + try { + setIsSubmitting(true); + if (!selectedAgentPolicyId) { + throw new Error('No selected agent policy id'); + } + const res = isSingleAgent + ? await sendPutAgentReassign((agents[0] as Agent).id, { + policy_id: selectedAgentPolicyId, + }) + : await sendPostBulkAgentReassign({ + policy_id: selectedAgentPolicyId, + agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, + }); + if (res.error) { + throw res.error; + } + setIsSubmitting(false); + const successMessage = i18n.translate( + 'xpack.fleet.agentReassignPolicy.successSingleNotificationTitle', + { + defaultMessage: 'Agent policy reassigned', + } + ); + notifications.toasts.addSuccess(successMessage); + onClose(); + } catch (error) { + setIsSubmitting(false); + notifications.toasts.addError(error, { + title: 'Unable to reassign agent policy', + }); + } + } + + return ( + + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={ + isSubmitting || (isSingleAgent && selectedAgentPolicyId === (agents[0] as Agent).policy_id) + } + confirmButtonText={ + + } + buttonColor="danger" + > +

+ +

+ + + + setSelectedAgentPolicyId(e.target.value)} + /> + + + + + + {selectedAgentPolicyId && } +
+ ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx index 696acb49abef3..5507fd6395c6f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx @@ -40,11 +40,11 @@ export const AgentUnenrollAgentModal: React.FunctionComponent = ({ setIsSubmitting(true); const { error } = isSingleAgent ? await sendPostAgentUnenroll((agents[0] as Agent).id, { - force: forceUnenroll, + revoke: forceUnenroll, }) : await sendPostBulkAgentUnenroll({ agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, - force: forceUnenroll, + revoke: forceUnenroll, }); if (error) { throw error; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx new file mode 100644 index 0000000000000..4d6ac864ee8b5 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx @@ -0,0 +1,167 @@ +/* + * 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. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiLink, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { sendPutSettings, useLink, useStartServices } from '../../../hooks'; + +interface Props { + onClose: () => void; +} + +export const FleetServerUpgradeModal: React.FunctionComponent = ({ onClose }) => { + const { getAssetsPath } = useLink(); + const { notifications, cloud } = useStartServices(); + + const isCloud = !!cloud?.cloudId; + + const [checked, setChecked] = useState(false); + const onChange = useCallback(async () => { + try { + setChecked(!checked); + await sendPutSettings({ + has_seen_fleet_migration_notice: !checked, + }); + } catch (error) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.fleetServerUpgradeModal.failedUpdateTitle', { + defaultMessage: `Error saving settings`, + }), + }); + } + }, [checked, setChecked, notifications]); + + return ( + + + + + + + + + + + {isCloud ? ( + + + + ), + link: ( + + + + ), + }} + /> + ) : ( + + + + ), + link: ( + + + + ), + }} + /> + )} + + + + + + + ), + }} + /> + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/index.tsx index 93425ba10413c..45f09c79d5533 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/index.tsx @@ -6,7 +6,7 @@ */ export * from './loading'; -export * from './agent_reassign_policy_flyout'; +export * from './agent_reassign_policy_modal'; export * from './agent_enrollment_flyout'; export * from './agent_health'; export * from './agent_unenroll_modal'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx index b8c9ead6773f5..56eb4072c3847 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx @@ -5,13 +5,19 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; import { PAGE_ROUTING_PATHS } from '../../constants'; import { Loading, Error } from '../../components'; -import { useConfig, useFleetStatus, useBreadcrumbs, useCapabilities } from '../../hooks'; +import { + useConfig, + useFleetStatus, + useBreadcrumbs, + useCapabilities, + useGetSettings, +} from '../../hooks'; import { WithoutHeaderLayout } from '../../layouts'; import { AgentListPage } from './agent_list_page'; @@ -20,6 +26,7 @@ import { AgentDetailsPage } from './agent_details_page'; import { NoAccessPage } from './error_pages/no_access'; import { EnrollmentTokenListPage } from './enrollment_token_list_page'; import { ListLayout } from './components/list_layout'; +import { FleetServerUpgradeModal } from './components/fleet_server_upgrade_modal'; const REFRESH_INTERVAL_MS = 30000; @@ -31,6 +38,20 @@ export const FleetApp: React.FunctionComponent = () => { const fleetStatus = useFleetStatus(); + const settings = useGetSettings(); + + const [fleetServerModalVisible, setFleetServerModalVisible] = useState(false); + const onCloseFleetServerModal = useCallback(() => { + setFleetServerModalVisible(false); + }, [setFleetServerModalVisible]); + + useEffect(() => { + // if it's undefined do not show the modal + if (settings.data && settings.data?.item.has_seen_fleet_migration_notice === false) { + setFleetServerModalVisible(true); + } + }, [settings.data]); + useEffect(() => { if ( !agents.enabled || @@ -99,6 +120,9 @@ export const FleetApp: React.FunctionComponent = () => { + {fleetServerModalVisible && ( + + )} {hasOnlyFleetServerMissingRequirement ? ( ) : ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/package_policies.tsx index 94b4b748cb1bd..b14551098e688 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/package_policies.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import type { ReactNode } from 'react'; import React, { memo, useCallback, useMemo } from 'react'; import { Redirect } from 'react-router-dom'; import type { CriteriaWithPagination, EuiTableFieldDataColumnType } from '@elastic/eui'; @@ -16,8 +15,7 @@ import { FormattedRelative, FormattedMessage } from '@kbn/i18n/react'; import { InstallStatus } from '../../../../../types'; import { useLink, useUrlPagination } from '../../../../../hooks'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; -import type { LinkAndRevisionProps } from '../../../../../components'; -import { LinkAndRevision } from '../../../../../components'; +import { AgentPolicySummaryLine } from '../../../../../components'; import { LinkedAgentCount } from '../../../../../components/linked_agent_count'; import { useGetPackageInstallStatus } from '../../../hooks'; @@ -42,27 +40,6 @@ const IntegrationDetailsLink = memo<{ ); }); - -const AgentPolicyDetailLink = memo<{ - agentPolicyId: string; - revision: LinkAndRevisionProps['revision']; - children: ReactNode; -}>(({ agentPolicyId, revision, children }) => { - const { getHref } = useLink(); - - return ( - - {children} - - ); -}); - interface PackagePoliciesPanelProps { name: string; version: string; @@ -112,11 +89,7 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps }), truncateText: true, render(id, { agentPolicy }) { - return ( - - {agentPolicy.name ?? id} - - ); + return ; }, }, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts index 89aa5ad1add35..0d85bfcdb6af6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts @@ -88,6 +88,7 @@ export { PutSettingsResponse, // API schemas - app CheckPermissionsResponse, + GenerateServiceTokenResponse, // EPM types AssetReference, AssetsGroupedByServiceByType, diff --git a/x-pack/plugins/fleet/public/assets/announcement.jpg b/x-pack/plugins/fleet/public/assets/announcement.jpg new file mode 100644 index 0000000000000..65191f1da6c53 Binary files /dev/null and b/x-pack/plugins/fleet/public/assets/announcement.jpg differ diff --git a/x-pack/plugins/fleet/scripts/dev_agent/script.ts b/x-pack/plugins/fleet/scripts/dev_agent/script.ts deleted file mode 100644 index b4bdea0c28996..0000000000000 --- a/x-pack/plugins/fleet/scripts/dev_agent/script.ts +++ /dev/null @@ -1,143 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import os from 'os'; - -import type { ToolingLog } from '@kbn/dev-utils'; -import { createFlagError, run } from '@kbn/dev-utils'; -import fetch from 'node-fetch'; - -import type { - Agent as _Agent, - PostAgentCheckinRequest, - PostAgentCheckinResponse, - PostAgentEnrollRequest, - PostAgentEnrollResponse, -} from '../../common/types'; -import * as kibanaPackage from '../../package.json'; - -// @ts-ignore -// Using the ts-ignore because we are importing directly from a json to a script file -const version = kibanaPackage.version; -const CHECKIN_INTERVAL = 3000; // 3 seconds - -type Agent = Pick<_Agent, 'id' | 'access_api_key'>; - -let closing = false; - -process.once('SIGINT', () => { - closing = true; -}); - -run( - async ({ flags, log }) => { - if (!flags.kibanaUrl || typeof flags.kibanaUrl !== 'string') { - throw createFlagError('please provide a single --path flag'); - } - - if (!flags.enrollmentApiKey || typeof flags.enrollmentApiKey !== 'string') { - throw createFlagError('please provide a single --enrollmentApiKey flag'); - } - const kibanaUrl = flags.kibanaUrl || 'http://localhost:5601'; - const agent = await enroll(kibanaUrl, flags.enrollmentApiKey, log); - - log.info('Enrolled with sucess', agent); - - while (!closing) { - await checkin(kibanaUrl, agent, log); - await new Promise((resolve, reject) => setTimeout(() => resolve(), CHECKIN_INTERVAL)); - } - }, - { - description: ` - Run a fleet development agent. - `, - flags: { - string: ['kibanaUrl', 'enrollmentApiKey'], - help: ` - --kibanaUrl kibanaURL to run the fleet agent - --enrollmentApiKey enrollment api key - `, - }, - } -); - -async function checkin(kibanaURL: string, agent: Agent, log: ToolingLog) { - const body: PostAgentCheckinRequest['body'] = { - events: [ - { - type: 'STATE', - subtype: 'RUNNING', - message: 'state changed from STOPPED to RUNNING', - timestamp: new Date().toISOString(), - payload: { - random: 'data', - state: 'RUNNING', - previous_state: 'STOPPED', - }, - agent_id: agent.id, - }, - ], - }; - const res = await fetch(`${kibanaURL}/api/fleet/agents/${agent.id}/checkin`, { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'kbn-xsrf': 'xxx', - Authorization: `ApiKey ${agent.access_api_key}`, - 'Content-Type': 'application/json', - }, - }); - - if (res.status === 403) { - closing = true; - log.info('Unenrolling agent'); - return; - } - - const obj: PostAgentCheckinResponse = await res.json(); - log.info('checkin', obj); -} - -async function enroll(kibanaURL: string, apiKey: string, log: ToolingLog): Promise { - const body: PostAgentEnrollRequest['body'] = { - type: 'PERMANENT', - metadata: { - local: { - host: 'localhost', - ip: '127.0.0.1', - system: `${os.type()} ${os.release()}`, - memory: os.totalmem(), - elastic: { agent: { version } }, - }, - user_provided: { - dev_agent_version: '0.0.1', - region: 'us-east', - }, - }, - }; - const res = await fetch(`${kibanaURL}/api/fleet/agents/enroll`, { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'kbn-xsrf': 'xxx', - Authorization: `ApiKey ${apiKey}`, - 'Content-Type': 'application/json', - }, - }); - const obj: PostAgentEnrollResponse = await res.json(); - - if (!res.ok) { - log.error(JSON.stringify(obj, null, 2)); - throw new Error('unable to enroll'); - } - - return { - id: obj.item.id, - access_api_key: obj.item.access_api_key, - }; -} diff --git a/x-pack/plugins/fleet/scripts/readme.md b/x-pack/plugins/fleet/scripts/readme.md deleted file mode 100644 index efec40b0aba1e..0000000000000 --- a/x-pack/plugins/fleet/scripts/readme.md +++ /dev/null @@ -1,8 +0,0 @@ -### Dev agents - -You can run a development fleet agent that is going to enroll and checkin every 3 seconds. -For this you can run the following command in the fleet pluging directory. - -``` -node scripts/dev_agent --enrollmentApiKey= --kibanaUrl=http://localhost:5603/qed -``` diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index b3b6bb5b4ea22..aa4fbd9cfeb97 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -24,7 +24,6 @@ export { DATA_STREAM_API_ROUTES, PACKAGE_POLICY_API_ROUTES, AGENT_API_ROUTES, - AGENT_API_ROUTES_7_9, AGENT_POLICY_API_ROUTES, AGENTS_SETUP_API_ROUTES, ENROLLMENT_API_KEY_ROUTES, @@ -54,4 +53,5 @@ export { ENROLLMENT_API_KEYS_INDEX, AGENTS_INDEX, PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, + PRECONFIGURATION_LATEST_KEYWORD, } from '../../common'; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index 6738e078e8b75..793a349f730f3 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -47,6 +47,7 @@ export class AgentUnenrollmentError extends IngestManagerError {} export class AgentPolicyDeletionError extends IngestManagerError {} export class FleetSetupError extends IngestManagerError {} +export class GenerateServiceTokenError extends IngestManagerError {} export class ArtifactsClientError extends IngestManagerError {} export class ArtifactsClientAccessDeniedError extends IngestManagerError { diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index c66dd471690eb..25298d991230d 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -9,12 +9,6 @@ import { schema } from '@kbn/config-schema'; import type { TypeOf } from '@kbn/config-schema'; import type { PluginConfigDescriptor, PluginInitializerContext } from 'src/core/server'; -import { - AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS, - AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL, - AGENT_POLLING_REQUEST_TIMEOUT_MS, -} from '../common'; - import { PreconfiguredPackagesSchema, PreconfiguredAgentPoliciesSchema } from './types'; import { FleetPlugin } from './plugin'; @@ -40,6 +34,13 @@ export const config: PluginConfigDescriptor = { deprecations: ({ renameFromRoot, unused }) => [ renameFromRoot('xpack.ingestManager', 'xpack.fleet'), renameFromRoot('xpack.fleet.fleet', 'xpack.fleet.agents'), + unused('agents.kibana'), + unused('agents.maxConcurrentConnections'), + unused('agents.agentPolicyRolloutRateLimitIntervalMs'), + unused('agents.agentPolicyRolloutRateLimitRequestPerInterval'), + unused('agents.pollingRequestTimeout'), + unused('agents.tlsCheckDisabled'), + unused('agents.fleetServerEnabled'), ], schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -47,22 +48,6 @@ export const config: PluginConfigDescriptor = { registryProxyUrl: schema.maybe(schema.uri({ scheme: ['http', 'https'] })), agents: schema.object({ enabled: schema.boolean({ defaultValue: true }), - fleetServerEnabled: schema.boolean({ defaultValue: false }), - tlsCheckDisabled: schema.boolean({ defaultValue: false }), - pollingRequestTimeout: schema.number({ - defaultValue: AGENT_POLLING_REQUEST_TIMEOUT_MS, - min: 5000, - }), - maxConcurrentConnections: schema.number({ defaultValue: 0 }), - kibana: schema.object({ - host: schema.maybe( - schema.oneOf([ - schema.uri({ scheme: ['http', 'https'] }), - schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { minSize: 1 }), - ]) - ), - ca_sha256: schema.maybe(schema.string()), - }), elasticsearch: schema.object({ host: schema.maybe(schema.string()), ca_sha256: schema.maybe(schema.string()), @@ -72,12 +57,6 @@ export const config: PluginConfigDescriptor = { hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), }) ), - agentPolicyRolloutRateLimitIntervalMs: schema.number({ - defaultValue: AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS, - }), - agentPolicyRolloutRateLimitRequestPerInterval: schema.number({ - defaultValue: AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL, - }), }), packages: schema.maybe(PreconfiguredPackagesSchema), agentPolicies: schema.maybe(PreconfiguredAgentPoliciesSchema), diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index d25b1e13904db..704df5970b345 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -52,16 +52,13 @@ import { } from './constants'; import { registerSavedObjects, registerEncryptedSavedObjects } from './saved_objects'; import { - registerLimitedConcurrencyRoutes, registerEPMRoutes, registerPackagePolicyRoutes, registerDataStreamRoutes, registerAgentPolicyRoutes, registerSetupRoutes, registerAgentAPIRoutes, - registerElasticAgentRoutes, registerEnrollmentApiKeyRoutes, - registerInstallScriptRoutes, registerOutputRoutes, registerSettingsRoutes, registerAppRoutes, @@ -86,7 +83,6 @@ import { getAgentsByKuery, getAgentById, } from './services/agents'; -import { agentCheckinState } from './services/agents/checkin/state'; import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation } from './services/epm/packages'; import { makeRouterEnforcingSuperuser } from './routes/security'; @@ -220,8 +216,6 @@ export class FleetPlugin const config = await this.config$.pipe(first()).toPromise(); - appContextService.fleetServerEnabled = config.agents.fleetServerEnabled; - registerSavedObjects(core.savedObjects, deps.encryptedSavedObjects); registerEncryptedSavedObjects(deps.encryptedSavedObjects); @@ -281,26 +275,8 @@ export class FleetPlugin // Conditional config routes if (config.agents.enabled) { - const isESOCanEncrypt = deps.encryptedSavedObjects.canEncrypt; - if (!isESOCanEncrypt) { - if (this.logger) { - this.logger.warn( - 'Fleet APIs are disabled because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' - ); - } - } else { - // we currently only use this global interceptor if fleet is enabled - // since it would run this func on *every* req (other plugins, CSS, etc) - registerLimitedConcurrencyRoutes(core, config); - registerAgentAPIRoutes(routerSuperuserOnly, config); - registerEnrollmentApiKeyRoutes(routerSuperuserOnly); - registerInstallScriptRoutes({ - router: routerSuperuserOnly, - basePath: core.http.basePath, - }); - // Do not enforce superuser role for Elastic Agent routes - registerElasticAgentRoutes(router, config); - } + registerAgentAPIRoutes(routerSuperuserOnly, config); + registerEnrollmentApiKeyRoutes(routerSuperuserOnly); } } } @@ -322,7 +298,6 @@ export class FleetPlugin logger: this.logger, }); licenseService.start(this.licensing$); - agentCheckinState.start(); const fleetServerSetup = startFleetServerSetup(); @@ -366,6 +341,5 @@ export class FleetPlugin public async stop() { appContextService.stop(); licenseService.stop(); - agentCheckinState.stop(); } } diff --git a/x-pack/plugins/fleet/server/routes/agent/acks_handlers.test.ts b/x-pack/plugins/fleet/server/routes/agent/acks_handlers.test.ts deleted file mode 100644 index c4cd58226697c..0000000000000 --- a/x-pack/plugins/fleet/server/routes/agent/acks_handlers.test.ts +++ /dev/null @@ -1,104 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { - ElasticsearchClient, - KibanaResponseFactory, - RequestHandlerContext, - SavedObjectsClientContract, -} from 'kibana/server'; - -import { - elasticsearchServiceMock, - httpServerMock, - savedObjectsClientMock, -} from '../../../../../../src/core/server/mocks'; -import type { PostAgentAcksResponse } from '../../../common/types/rest_spec'; -import { AckEventSchema } from '../../types/models'; -import type { AcksService } from '../../services/agents'; - -import { postAgentAcksHandlerBuilder } from './acks_handlers'; - -describe('test acks schema', () => { - it('validate that ack event schema expect action id', async () => { - expect(() => - AckEventSchema.validate({ - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - agent_id: 'agent', - message: 'hello', - payload: 'payload', - }) - ).toThrow(Error); - - expect( - AckEventSchema.validate({ - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - agent_id: 'agent', - action_id: 'actionId', - message: 'hello', - payload: 'payload', - }) - ).toBeTruthy(); - }); -}); - -describe('test acks handlers', () => { - let mockResponse: jest.Mocked; - let mockSavedObjectsClient: jest.Mocked; - let mockElasticsearchClient: jest.Mocked; - - beforeEach(() => { - mockSavedObjectsClient = savedObjectsClientMock.create(); - mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - mockResponse = httpServerMock.createResponseFactory(); - }); - - it('should succeed on valid agent event', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ - headers: { - authorization: 'ApiKey TmVqTDBIQUJsRkw1em52R1ZIUF86NS1NaTItdHFUTHFHbThmQW1Fb0ljUQ==', - }, - body: { - events: [ - { - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - action_id: 'action1', - agent_id: 'agent', - message: 'message', - }, - ], - }, - }); - - const ackService: AcksService = { - acknowledgeAgentActions: jest.fn().mockReturnValueOnce([ - { - type: 'POLICY_CHANGE', - id: 'action1', - }, - ]), - authenticateAgentWithAccessToken: jest.fn().mockReturnValueOnce({ - id: 'agent', - }), - getSavedObjectsClientContract: jest.fn().mockReturnValueOnce(mockSavedObjectsClient), - getElasticsearchClientContract: jest.fn().mockReturnValueOnce(mockElasticsearchClient), - saveAgentEvents: jest.fn(), - } as jest.Mocked; - - const postAgentAcksHandler = postAgentAcksHandlerBuilder(ackService); - await postAgentAcksHandler(({} as unknown) as RequestHandlerContext, mockRequest, mockResponse); - expect(mockResponse.ok.mock.calls[0][0]?.body as PostAgentAcksResponse).toEqual({ - action: 'acks', - }); - }); -}); diff --git a/x-pack/plugins/fleet/server/routes/agent/acks_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/acks_handlers.ts deleted file mode 100644 index d4c84d19546c9..0000000000000 --- a/x-pack/plugins/fleet/server/routes/agent/acks_handlers.ts +++ /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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// handlers that handle events from agents in response to actions received - -import type { RequestHandler } from 'kibana/server'; - -import type { AcksService } from '../../services/agents'; -import type { AgentEvent } from '../../../common/types/models'; -import type { PostAgentAcksRequest, PostAgentAcksResponse } from '../../../common/types/rest_spec'; -import { defaultIngestErrorHandler } from '../../errors'; - -export const postAgentAcksHandlerBuilder = function ( - ackService: AcksService -): RequestHandler { - return async (context, request, response) => { - try { - const soClient = ackService.getSavedObjectsClientContract(request); - const esClient = ackService.getElasticsearchClientContract(); - const agent = await ackService.authenticateAgentWithAccessToken(esClient, request); - const agentEvents = request.body.events as AgentEvent[]; - - // validate that all events are for the authorized agent obtained from the api key - const notAuthorizedAgentEvent = agentEvents.filter( - (agentEvent) => agentEvent.agent_id !== agent.id - ); - - if (notAuthorizedAgentEvent && notAuthorizedAgentEvent.length > 0) { - return response.badRequest({ - body: - 'agent events contains events with different agent id from currently authorized agent', - }); - } - - const agentActions = await ackService.acknowledgeAgentActions( - soClient, - esClient, - agent, - agentEvents - ); - - if (agentActions.length > 0) { - await ackService.saveAgentEvents(soClient, agentEvents); - } - - const body: PostAgentAcksResponse = { - action: 'acks', - }; - - return response.ok({ body }); - } catch (error) { - return defaultIngestErrorHandler({ error, response }); - } - }; -}; diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index f1e32f325dd0c..42769d4caf6cb 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -24,14 +24,13 @@ export const postNewAgentActionHandlerBuilder = function ( > { return async (context, request, response) => { try { - const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asInternalUser; const agent = await actionsService.getAgent(esClient, request.params.agentId); const newAgentAction = request.body.action; - const savedAgentAction = await actionsService.createAgentAction(soClient, esClient, { + const savedAgentAction = await actionsService.createAgentAction(esClient, { created_at: new Date().toISOString(), ...newAgentAction, agent_id: agent.id, diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index 5ac264e29f079..c485cae4b3e37 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -7,17 +7,13 @@ import type { RequestHandler } from 'src/core/server'; import type { TypeOf } from '@kbn/config-schema'; -import { AbortController } from 'abort-controller'; import type { GetAgentsResponse, GetOneAgentResponse, GetOneAgentEventsResponse, - PostAgentCheckinResponse, - PostAgentEnrollResponse, GetAgentStatusResponse, PutAgentReassignResponse, - PostAgentEnrollRequest, PostBulkAgentReassignResponse, } from '../../../common/types'; import type { @@ -30,12 +26,9 @@ import type { PutAgentReassignRequestSchema, PostBulkAgentReassignRequestSchema, } from '../../types'; -import type { PostAgentCheckinRequest } from '../../types'; import { defaultIngestErrorHandler } from '../../errors'; import { licenseService } from '../../services'; import * as AgentService from '../../services/agents'; -import * as APIKeyService from '../../services/api_keys'; -import { appContextService } from '../../services/app_context'; export const getAgentHandler: RequestHandler< TypeOf @@ -153,89 +146,6 @@ export const updateAgentHandler: RequestHandler< } }; -export const postAgentCheckinHandler: RequestHandler< - PostAgentCheckinRequest['params'], - undefined, - PostAgentCheckinRequest['body'] -> = async (context, request, response) => { - try { - const soClient = appContextService.getInternalUserSOClient(request); - const esClient = appContextService.getInternalUserESClient(); - const agent = await AgentService.authenticateAgentWithAccessToken(esClient, request); - const abortController = new AbortController(); - request.events.aborted$.subscribe(() => { - abortController.abort(); - }); - const signal = abortController.signal; - - const { actions } = await AgentService.agentCheckin( - soClient, - esClient, - agent, - { - events: request.body.events || [], - localMetadata: request.body.local_metadata, - status: request.body.status, - }, - { signal } - ); - const body: PostAgentCheckinResponse = { - action: 'checkin', - actions: actions.map((a) => ({ - agent_id: agent.id, - type: a.type, - data: a.data, - id: a.id, - created_at: a.created_at, - })), - }; - - return response.ok({ body }); - } catch (error) { - return defaultIngestErrorHandler({ error, response }); - } -}; - -export const postAgentEnrollHandler: RequestHandler< - undefined, - undefined, - PostAgentEnrollRequest['body'] -> = async (context, request, response) => { - try { - const soClient = appContextService.getInternalUserSOClient(request); - const esClient = context.core.elasticsearch.client.asInternalUser; - const { apiKeyId } = APIKeyService.parseApiKeyFromHeaders(request.headers); - const enrollmentAPIKey = await APIKeyService.getEnrollmentAPIKeyById(esClient, apiKeyId); - - if (!enrollmentAPIKey || !enrollmentAPIKey.active) { - return response.unauthorized({ - body: { message: 'Invalid Enrollment API Key' }, - }); - } - - const agent = await AgentService.enroll( - soClient, - request.body.type, - enrollmentAPIKey.policy_id as string, - { - userProvided: request.body.metadata.user_provided, - local: request.body.metadata.local, - } - ); - const body: PostAgentEnrollResponse = { - action: 'created', - item: { - ...agent, - status: AgentService.getAgentStatus(agent), - }, - }; - - return response.ok({ body }); - } catch (error) { - return defaultIngestErrorHandler({ error, response }); - } -}; - export const getAgentsHandler: RequestHandler< undefined, TypeOf diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index ec75768e816fe..9b33443d0dca3 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -5,38 +5,25 @@ * 2.0. */ -import type { IRouter, RouteValidationResultFactory } from 'src/core/server'; -import Ajv from 'ajv'; +import type { IRouter } from 'src/core/server'; -import { - PLUGIN_ID, - AGENT_API_ROUTES, - AGENT_API_ROUTES_7_9, - LIMITED_CONCURRENCY_ROUTE_TAG, - AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS, -} from '../../constants'; +import { PLUGIN_ID, AGENT_API_ROUTES } from '../../constants'; import { GetAgentsRequestSchema, GetOneAgentRequestSchema, GetOneAgentEventsRequestSchema, UpdateAgentRequestSchema, DeleteAgentRequestSchema, - PostAgentCheckinRequestBodyJSONSchema, - PostAgentCheckinRequestParamsJSONSchema, - PostAgentAcksRequestParamsJSONSchema, - PostAgentAcksRequestBodyJSONSchema, PostAgentUnenrollRequestSchema, PostBulkAgentUnenrollRequestSchema, GetAgentStatusRequestSchema, PostNewAgentActionRequestSchema, PutAgentReassignRequestSchema, PostBulkAgentReassignRequestSchema, - PostAgentEnrollRequestBodyJSONSchema, PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema, } from '../../types'; import * as AgentService from '../../services/agents'; -import { appContextService } from '../../services'; import type { FleetConfigType } from '../..'; import { @@ -45,40 +32,14 @@ import { updateAgentHandler, deleteAgentHandler, getAgentEventsHandler, - postAgentCheckinHandler, - postAgentEnrollHandler, getAgentStatusForAgentPolicyHandler, putAgentsReassignHandler, postBulkAgentsReassignHandler, } from './handlers'; -import { postAgentAcksHandlerBuilder } from './acks_handlers'; import { postNewAgentActionHandlerBuilder } from './actions_handlers'; import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler'; import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler'; -const ajv = new Ajv({ - coerceTypes: true, - useDefaults: true, - removeAdditional: true, - allErrors: false, - nullable: true, -}); - -function schemaErrorsText(errors: Ajv.ErrorObject[], dataVar: any) { - return errors.map((e) => `${dataVar + (e.dataPath || '')} ${e.message}`).join(', '); -} - -function makeValidator(jsonSchema: any) { - const validator = ajv.compile(jsonSchema); - return function validateWithAJV(data: any, r: RouteValidationResultFactory) { - if (validator(data)) { - return r.ok(data); - } - - return r.badRequest(schemaErrorsText(validator.errors || [], data)); - }; -} - export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { // Get one router.get( @@ -205,119 +166,3 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { postBulkAgentsUnenrollHandler ); }; - -export const registerElasticAgentRoutes = (router: IRouter, config: FleetConfigType) => { - const pollingRequestTimeout = config.agents.pollingRequestTimeout; - // Agent checkin - router.post( - { - path: AGENT_API_ROUTES.CHECKIN_PATTERN, - validate: { - params: makeValidator(PostAgentCheckinRequestParamsJSONSchema), - body: makeValidator(PostAgentCheckinRequestBodyJSONSchema), - }, - options: { - tags: [], - // If the timeout is too short, do not set socket idle timeout and rely on Kibana global socket timeout - ...(pollingRequestTimeout && pollingRequestTimeout > AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS - ? { - timeout: { - idleSocket: pollingRequestTimeout, - }, - } - : {}), - }, - }, - postAgentCheckinHandler - ); - // BWC for agent <= 7.9 - router.post( - { - path: AGENT_API_ROUTES_7_9.CHECKIN_PATTERN, - validate: { - params: makeValidator(PostAgentCheckinRequestParamsJSONSchema), - body: makeValidator(PostAgentCheckinRequestBodyJSONSchema), - }, - options: { - tags: [], - // If the timeout is too short, do not set socket idle timeout and rely on Kibana global socket timeout - ...(pollingRequestTimeout && pollingRequestTimeout > AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS - ? { - timeout: { - idleSocket: pollingRequestTimeout, - }, - } - : {}), - }, - }, - postAgentCheckinHandler - ); - - // Agent enrollment - router.post( - { - path: AGENT_API_ROUTES.ENROLL_PATTERN, - validate: { - body: makeValidator(PostAgentEnrollRequestBodyJSONSchema), - }, - options: { tags: [LIMITED_CONCURRENCY_ROUTE_TAG] }, - }, - postAgentEnrollHandler - ); - // BWC for agent <= 7.9 - router.post( - { - path: AGENT_API_ROUTES_7_9.ENROLL_PATTERN, - validate: { - body: makeValidator(PostAgentEnrollRequestBodyJSONSchema), - }, - options: { tags: [LIMITED_CONCURRENCY_ROUTE_TAG] }, - }, - postAgentEnrollHandler - ); - - // Agent acks - router.post( - { - path: AGENT_API_ROUTES.ACKS_PATTERN, - validate: { - params: makeValidator(PostAgentAcksRequestParamsJSONSchema), - body: makeValidator(PostAgentAcksRequestBodyJSONSchema), - }, - options: { tags: [LIMITED_CONCURRENCY_ROUTE_TAG] }, - }, - postAgentAcksHandlerBuilder({ - acknowledgeAgentActions: AgentService.acknowledgeAgentActions, - authenticateAgentWithAccessToken: AgentService.authenticateAgentWithAccessToken, - getSavedObjectsClientContract: appContextService.getInternalUserSOClient.bind( - appContextService - ), - getElasticsearchClientContract: appContextService.getInternalUserESClient.bind( - appContextService - ), - saveAgentEvents: AgentService.saveAgentEvents, - }) - ); - // BWC for agent <= 7.9 - router.post( - { - path: AGENT_API_ROUTES_7_9.ACKS_PATTERN, - validate: { - params: makeValidator(PostAgentAcksRequestParamsJSONSchema), - body: makeValidator(PostAgentAcksRequestBodyJSONSchema), - }, - options: { tags: [LIMITED_CONCURRENCY_ROUTE_TAG] }, - }, - postAgentAcksHandlerBuilder({ - acknowledgeAgentActions: AgentService.acknowledgeAgentActions, - authenticateAgentWithAccessToken: AgentService.authenticateAgentWithAccessToken, - getSavedObjectsClientContract: appContextService.getInternalUserSOClient.bind( - appContextService - ), - getElasticsearchClientContract: appContextService.getInternalUserESClient.bind( - appContextService - ), - saveAgentEvents: AgentService.saveAgentEvents, - }) - ); -}; diff --git a/x-pack/plugins/fleet/server/routes/app/index.ts b/x-pack/plugins/fleet/server/routes/app/index.ts index ba7c649c4fa54..f2fc6302c8ce5 100644 --- a/x-pack/plugins/fleet/server/routes/app/index.ts +++ b/x-pack/plugins/fleet/server/routes/app/index.ts @@ -7,9 +7,10 @@ import type { IRouter, RequestHandler } from 'src/core/server'; -import { APP_API_ROUTES } from '../../constants'; +import { PLUGIN_ID, APP_API_ROUTES } from '../../constants'; import { appContextService } from '../../services'; -import type { CheckPermissionsResponse } from '../../../common'; +import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../../common'; +import { defaultIngestErrorHandler, GenerateServiceTokenError } from '../../errors'; export const getCheckPermissionsHandler: RequestHandler = async (context, request, response) => { const body: CheckPermissionsResponse = { success: true }; @@ -35,6 +36,29 @@ export const getCheckPermissionsHandler: RequestHandler = async (context, reques } }; +export const generateServiceTokenHandler: RequestHandler = async (context, request, response) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + try { + const { body: tokenResponse } = await esClient.transport.request({ + method: 'POST', + path: `_security/service/elastic/fleet-server/credential/token/token-${Date.now()}`, + }); + + if (tokenResponse.created && tokenResponse.token) { + const body: GenerateServiceTokenResponse = tokenResponse.token; + return response.ok({ + body, + }); + } else { + const error = new GenerateServiceTokenError('Unable to generate service token'); + return defaultIngestErrorHandler({ error, response }); + } + } catch (e) { + const error = new GenerateServiceTokenError(e); + return defaultIngestErrorHandler({ error, response }); + } +}; + export const registerRoutes = (router: IRouter) => { router.get( { @@ -44,4 +68,13 @@ export const registerRoutes = (router: IRouter) => { }, getCheckPermissionsHandler ); + + router.post( + { + path: APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN, + validate: {}, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + generateServiceTokenHandler + ); }; diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index 6d4d107adb796..aa36a3a7562bf 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -14,7 +14,7 @@ import type { GetDataStreamsResponse } from '../../../common'; import { getPackageSavedObjects } from '../../services/epm/packages/get'; import { defaultIngestErrorHandler } from '../../errors'; -const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*,traces-*-*'; +const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*,traces-*-*,synthetics-*-*'; interface ESDataStreamInfo { name: string; diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index f0d6e68427361..16d583f8a8d1f 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -226,20 +226,21 @@ export const installPackageFromRegistryHandler: RequestHandler< const savedObjectsClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; const { pkgkey } = request.params; - try { - const res = await installPackage({ - installSource: 'registry', - savedObjectsClient, - pkgkey, - esClient, - force: request.body?.force, - }); + + const res = await installPackage({ + installSource: 'registry', + savedObjectsClient, + pkgkey, + esClient, + force: request.body?.force, + }); + if (!res.error) { const body: InstallPackageResponse = { - response: res.assets, + response: res.assets || [], }; return response.ok({ body }); - } catch (e) { - return await defaultIngestErrorHandler({ error: e, response }); + } else { + return await defaultIngestErrorHandler({ error: res.error, response }); } }; @@ -292,20 +293,21 @@ export const installPackageByUploadHandler: RequestHandler< const esClient = context.core.elasticsearch.client.asCurrentUser; const contentType = request.headers['content-type'] as string; // from types it could also be string[] or undefined but this is checked later const archiveBuffer = Buffer.from(request.body); - try { - const res = await installPackage({ - installSource: 'upload', - savedObjectsClient, - esClient, - archiveBuffer, - contentType, - }); + + const res = await installPackage({ + installSource: 'upload', + savedObjectsClient, + esClient, + archiveBuffer, + contentType, + }); + if (!res.error) { const body: InstallPackageResponse = { - response: res.assets, + response: res.assets || [], }; return response.ok({ body }); - } catch (error) { - return defaultIngestErrorHandler({ error, response }); + } else { + return defaultIngestErrorHandler({ error: res.error, response }); } }; diff --git a/x-pack/plugins/fleet/server/routes/index.ts b/x-pack/plugins/fleet/server/routes/index.ts index 4d5a4b1e64dc0..bcdc2db54ae0c 100644 --- a/x-pack/plugins/fleet/server/routes/index.ts +++ b/x-pack/plugins/fleet/server/routes/index.ts @@ -10,14 +10,9 @@ export { registerRoutes as registerPackagePolicyRoutes } from './package_policy' export { registerRoutes as registerDataStreamRoutes } from './data_streams'; export { registerRoutes as registerEPMRoutes } from './epm'; export { registerRoutes as registerSetupRoutes } from './setup'; -export { - registerAPIRoutes as registerAgentAPIRoutes, - registerElasticAgentRoutes as registerElasticAgentRoutes, -} from './agent'; +export { registerAPIRoutes as registerAgentAPIRoutes } from './agent'; export { registerRoutes as registerEnrollmentApiKeyRoutes } from './enrollment_api_key'; -export { registerRoutes as registerInstallScriptRoutes } from './install_script'; export { registerRoutes as registerOutputRoutes } from './output'; export { registerRoutes as registerSettingsRoutes } from './settings'; export { registerRoutes as registerAppRoutes } from './app'; -export { registerLimitedConcurrencyRoutes } from './limited_concurrency'; export { registerRoutes as registerPreconfigurationRoutes } from './preconfiguration'; diff --git a/x-pack/plugins/fleet/server/routes/install_script/index.ts b/x-pack/plugins/fleet/server/routes/install_script/index.ts deleted file mode 100644 index 673fe237700cf..0000000000000 --- a/x-pack/plugins/fleet/server/routes/install_script/index.ts +++ /dev/null @@ -1,60 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import url from 'url'; - -import type { BasePath, KibanaRequest } from 'src/core/server'; -import type { IRouter } from 'src/core/server'; - -import { INSTALL_SCRIPT_API_ROUTES } from '../../constants'; -import { getScript } from '../../services/install_script'; -import { InstallScriptRequestSchema } from '../../types'; -import { appContextService, settingsService } from '../../services'; - -function getInternalUserSOClient(request: KibanaRequest) { - // soClient as kibana internal users, be carefull on how you use it, security is not enabled - return appContextService.getSavedObjects().getScopedClient(request, { - excludedWrappers: ['security'], - }); -} - -export const registerRoutes = ({ - router, -}: { - router: IRouter; - basePath: Pick; -}) => { - router.get( - { - path: INSTALL_SCRIPT_API_ROUTES, - validate: InstallScriptRequestSchema, - options: { tags: [], authRequired: false }, - }, - async function getInstallScriptHandler( - context, - request: KibanaRequest<{ osType: 'macos' }>, - response - ) { - const soClient = getInternalUserSOClient(request); - const http = appContextService.getHttpSetup(); - const serverInfo = http.getServerInfo(); - const basePath = http.basePath; - const kibanaUrls = (await settingsService.getSettings(soClient)).kibana_urls || [ - url.format({ - protocol: serverInfo.protocol, - hostname: serverInfo.hostname, - port: serverInfo.port, - pathname: basePath.serverBasePath, - }), - ]; - - const script = getScript(request.params.osType, kibanaUrls[0]); - - return response.ok({ body: script }); - } - ); -}; diff --git a/x-pack/plugins/fleet/server/routes/limited_concurrency.test.ts b/x-pack/plugins/fleet/server/routes/limited_concurrency.test.ts deleted file mode 100644 index c645d8fceaab8..0000000000000 --- a/x-pack/plugins/fleet/server/routes/limited_concurrency.test.ts +++ /dev/null @@ -1,222 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { coreMock, httpServerMock, httpServiceMock } from 'src/core/server/mocks'; - -import type { FleetConfigType } from '../index'; - -import { - createLimitedPreAuthHandler, - isLimitedRoute, - registerLimitedConcurrencyRoutes, -} from './limited_concurrency'; - -describe('registerLimitedConcurrencyRoutes', () => { - test(`doesn't call registerOnPreAuth if maxConcurrentConnections is 0`, async () => { - const mockSetup = coreMock.createSetup(); - const mockConfig = { agents: { maxConcurrentConnections: 0 } } as FleetConfigType; - registerLimitedConcurrencyRoutes(mockSetup, mockConfig); - - expect(mockSetup.http.registerOnPreAuth).not.toHaveBeenCalled(); - }); - - test(`calls registerOnPreAuth once if maxConcurrentConnections is 1`, async () => { - const mockSetup = coreMock.createSetup(); - const mockConfig = { agents: { maxConcurrentConnections: 1 } } as FleetConfigType; - registerLimitedConcurrencyRoutes(mockSetup, mockConfig); - - expect(mockSetup.http.registerOnPreAuth).toHaveBeenCalledTimes(1); - }); - - test(`calls registerOnPreAuth once if maxConcurrentConnections is 1000`, async () => { - const mockSetup = coreMock.createSetup(); - const mockConfig = { agents: { maxConcurrentConnections: 1000 } } as FleetConfigType; - registerLimitedConcurrencyRoutes(mockSetup, mockConfig); - - expect(mockSetup.http.registerOnPreAuth).toHaveBeenCalledTimes(1); - }); -}); - -// assertions for calls to .decrease are commented out because it's called on the -// "req.events.completed$ observable (which) will never emit from a mocked request in a jest unit test environment" -// https://github.com/elastic/kibana/pull/72338#issuecomment-661908791 -describe('preAuthHandler', () => { - test(`ignores routes when !isMatch`, async () => { - const mockMaxCounter = { - increase: jest.fn(), - decrease: jest.fn(), - lessThanMax: jest.fn(), - }; - const preAuthHandler = createLimitedPreAuthHandler({ - isMatch: jest.fn().mockImplementation(() => false), - maxCounter: mockMaxCounter, - }); - - const mockRequest = httpServerMock.createKibanaRequest({ - path: '/no/match', - }); - const mockResponse = httpServerMock.createResponseFactory(); - const mockPreAuthToolkit = httpServiceMock.createOnPreAuthToolkit(); - - await preAuthHandler(mockRequest, mockResponse, mockPreAuthToolkit); - - expect(mockMaxCounter.increase).not.toHaveBeenCalled(); - expect(mockMaxCounter.decrease).not.toHaveBeenCalled(); - expect(mockMaxCounter.lessThanMax).not.toHaveBeenCalled(); - expect(mockPreAuthToolkit.next).toHaveBeenCalledTimes(1); - }); - - test(`ignores routes which don't have the correct tag`, async () => { - const mockMaxCounter = { - increase: jest.fn(), - decrease: jest.fn(), - lessThanMax: jest.fn(), - }; - const preAuthHandler = createLimitedPreAuthHandler({ - isMatch: isLimitedRoute, - maxCounter: mockMaxCounter, - }); - - const mockRequest = httpServerMock.createKibanaRequest({ - path: '/no/match', - }); - const mockResponse = httpServerMock.createResponseFactory(); - const mockPreAuthToolkit = httpServiceMock.createOnPreAuthToolkit(); - - await preAuthHandler(mockRequest, mockResponse, mockPreAuthToolkit); - - expect(mockMaxCounter.increase).not.toHaveBeenCalled(); - expect(mockMaxCounter.decrease).not.toHaveBeenCalled(); - expect(mockMaxCounter.lessThanMax).not.toHaveBeenCalled(); - expect(mockPreAuthToolkit.next).toHaveBeenCalledTimes(1); - }); - - test(`processes routes which have the correct tag`, async () => { - const mockMaxCounter = { - increase: jest.fn(), - decrease: jest.fn(), - lessThanMax: jest.fn().mockImplementation(() => true), - }; - const preAuthHandler = createLimitedPreAuthHandler({ - isMatch: isLimitedRoute, - maxCounter: mockMaxCounter, - }); - - const mockRequest = httpServerMock.createKibanaRequest({ - path: '/should/match', - routeTags: ['ingest:limited-concurrency'], - }); - const mockResponse = httpServerMock.createResponseFactory(); - const mockPreAuthToolkit = httpServiceMock.createOnPreAuthToolkit(); - - await preAuthHandler(mockRequest, mockResponse, mockPreAuthToolkit); - - // will call lessThanMax because isMatch succeeds - expect(mockMaxCounter.lessThanMax).toHaveBeenCalledTimes(1); - // will not error because lessThanMax is true - expect(mockResponse.customError).not.toHaveBeenCalled(); - expect(mockPreAuthToolkit.next).toHaveBeenCalledTimes(1); - }); - - test(`updates the counter when isMatch & lessThanMax`, async () => { - const mockMaxCounter = { - increase: jest.fn(), - decrease: jest.fn(), - lessThanMax: jest.fn().mockImplementation(() => true), - }; - const preAuthHandler = createLimitedPreAuthHandler({ - isMatch: jest.fn().mockImplementation(() => true), - maxCounter: mockMaxCounter, - }); - - const mockRequest = httpServerMock.createKibanaRequest(); - const mockResponse = httpServerMock.createResponseFactory(); - const mockPreAuthToolkit = httpServiceMock.createOnPreAuthToolkit(); - - await preAuthHandler(mockRequest, mockResponse, mockPreAuthToolkit); - - expect(mockMaxCounter.increase).toHaveBeenCalled(); - // expect(mockMaxCounter.decrease).toHaveBeenCalled(); - expect(mockPreAuthToolkit.next).toHaveBeenCalledTimes(1); - }); - - test(`lessThanMax ? next : error`, async () => { - const mockMaxCounter = { - increase: jest.fn(), - decrease: jest.fn(), - lessThanMax: jest - .fn() - // call 1 - .mockImplementationOnce(() => true) - // calls 2, 3, 4 - .mockImplementationOnce(() => false) - .mockImplementationOnce(() => false) - .mockImplementationOnce(() => false) - // calls 5+ - .mockImplementationOnce(() => true) - .mockImplementation(() => true), - }; - - const preAuthHandler = createLimitedPreAuthHandler({ - isMatch: isLimitedRoute, - maxCounter: mockMaxCounter, - }); - - function makeRequestExpectNext() { - const request = httpServerMock.createKibanaRequest({ - path: '/should/match/', - routeTags: ['ingest:limited-concurrency'], - }); - const response = httpServerMock.createResponseFactory(); - const toolkit = httpServiceMock.createOnPreAuthToolkit(); - - preAuthHandler(request, response, toolkit); - expect(toolkit.next).toHaveBeenCalledTimes(1); - expect(response.customError).not.toHaveBeenCalled(); - } - - function makeRequestExpectError() { - const request = httpServerMock.createKibanaRequest({ - path: '/should/match/', - routeTags: ['ingest:limited-concurrency'], - }); - const response = httpServerMock.createResponseFactory(); - const toolkit = httpServiceMock.createOnPreAuthToolkit(); - - preAuthHandler(request, response, toolkit); - expect(toolkit.next).not.toHaveBeenCalled(); - expect(response.customError).toHaveBeenCalledTimes(1); - expect(response.customError).toHaveBeenCalledWith({ - statusCode: 429, - body: 'Too Many Requests', - }); - } - - // request 1 succeeds - makeRequestExpectNext(); - expect(mockMaxCounter.increase).toHaveBeenCalledTimes(1); - // expect(mockMaxCounter.decrease).toHaveBeenCalledTimes(1); - - // requests 2, 3, 4 fail - makeRequestExpectError(); - makeRequestExpectError(); - makeRequestExpectError(); - - // requests 5+ succeed - makeRequestExpectNext(); - expect(mockMaxCounter.increase).toHaveBeenCalledTimes(2); - // expect(mockMaxCounter.decrease).toHaveBeenCalledTimes(2); - - makeRequestExpectNext(); - expect(mockMaxCounter.increase).toHaveBeenCalledTimes(3); - // expect(mockMaxCounter.decrease).toHaveBeenCalledTimes(3); - - makeRequestExpectNext(); - expect(mockMaxCounter.increase).toHaveBeenCalledTimes(4); - // expect(mockMaxCounter.decrease).toHaveBeenCalledTimes(4); - }); -}); diff --git a/x-pack/plugins/fleet/server/routes/limited_concurrency.ts b/x-pack/plugins/fleet/server/routes/limited_concurrency.ts deleted file mode 100644 index 6f9a2bc95ea20..0000000000000 --- a/x-pack/plugins/fleet/server/routes/limited_concurrency.ts +++ /dev/null @@ -1,90 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { KibanaRequest } from 'kibana/server'; -import type { - CoreSetup, - LifecycleResponseFactory, - OnPreAuthToolkit, - OnPreAuthHandler, -} from 'kibana/server'; - -import { LIMITED_CONCURRENCY_ROUTE_TAG } from '../../common'; -import type { FleetConfigType } from '../index'; - -export class MaxCounter { - constructor(private readonly max: number = 1) {} - private counter = 0; - valueOf() { - return this.counter; - } - increase() { - if (this.counter < this.max) { - this.counter += 1; - } - } - decrease() { - if (this.counter > 0) { - this.counter -= 1; - } - } - lessThanMax() { - return this.counter < this.max; - } -} - -export type IMaxCounter = Pick; - -export function isLimitedRoute(request: KibanaRequest) { - const tags = request.route.options.tags; - return !!tags.includes(LIMITED_CONCURRENCY_ROUTE_TAG); -} - -export function createLimitedPreAuthHandler({ - isMatch, - maxCounter, -}: { - isMatch: (request: KibanaRequest) => boolean; - maxCounter: IMaxCounter; -}): OnPreAuthHandler { - return function preAuthHandler( - request: KibanaRequest, - response: LifecycleResponseFactory, - toolkit: OnPreAuthToolkit - ) { - if (!isMatch(request)) { - return toolkit.next(); - } - - if (!maxCounter.lessThanMax()) { - return response.customError({ - body: 'Too Many Requests', - statusCode: 429, - }); - } - - maxCounter.increase(); - - request.events.completed$.toPromise().then(() => { - maxCounter.decrease(); - }); - - return toolkit.next(); - }; -} - -export function registerLimitedConcurrencyRoutes(core: CoreSetup, config: FleetConfigType) { - const max = config.agents.maxConcurrentConnections; - if (!max) return; - - core.http.registerOnPreAuth( - createLimitedPreAuthHandler({ - isMatch: isLimitedRoute, - maxCounter: new MaxCounter(max), - }) - ); -} diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts index 2cf9bbc3b91e3..fd32d699ae45e 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts @@ -46,7 +46,11 @@ describe('FleetSetupHandler', () => { it('POST /setup succeeds w/200 and body of resolved value', async () => { mockSetupIngestManager.mockImplementation(() => - Promise.resolve({ isInitialized: true, preconfigurationError: undefined }) + Promise.resolve({ + isInitialized: true, + preconfigurationError: undefined, + nonFatalPackageUpgradeErrors: [], + }) ); await fleetSetupHandler(context, request, response); diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index 41efb7b83d3be..a6d7acccfb4fe 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -6,14 +6,12 @@ */ import type { RequestHandler } from 'src/core/server'; -import type { TypeOf } from '@kbn/config-schema'; import { appContextService } from '../../services'; import type { GetFleetStatusResponse, PostIngestSetupResponse } from '../../../common'; -import { setupFleet, setupIngestManager } from '../../services/setup'; +import { setupIngestManager } from '../../services/setup'; import { hasFleetServers } from '../../services/fleet_server'; import { defaultIngestErrorHandler } from '../../errors'; -import type { PostFleetSetupRequestSchema } from '../../types'; export const getFleetStatusHandler: RequestHandler = async (context, request, response) => { try { @@ -21,15 +19,12 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re .getSecurity() .authc.apiKeys.areAPIKeysEnabled(); const isFleetServerSetup = await hasFleetServers(appContextService.getInternalUserESClient()); - const canEncrypt = appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt === true; const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; if (!isApiKeysEnabled) { missingRequirements.push('api_keys'); } - if (!canEncrypt) { - missingRequirements.push('encrypted_saved_object_encryption_key_required'); - } + if (!isFleetServerSetup) { missingRequirements.push('fleet_server'); } @@ -51,29 +46,14 @@ export const fleetSetupHandler: RequestHandler = async (context, request, respon try { const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; - const body: PostIngestSetupResponse = { isInitialized: true }; - await setupIngestManager(soClient, esClient); - - return response.ok({ - body, - }); - } catch (error) { - return defaultIngestErrorHandler({ error, response }); - } -}; + const setupStatus = await setupIngestManager(soClient, esClient); + const body: PostIngestSetupResponse = { + isInitialized: true, + }; -// TODO should be removed as part https://github.com/elastic/kibana/issues/94303 -export const fleetAgentSetupHandler: RequestHandler< - undefined, - undefined, - TypeOf -> = async (context, request, response) => { - try { - const soClient = context.core.savedObjects.client; - const esClient = context.core.elasticsearch.client.asCurrentUser; - const body: PostIngestSetupResponse = { isInitialized: true }; - await setupIngestManager(soClient, esClient); - await setupFleet(soClient, esClient, { forceRecreate: request.body?.forceRecreate === true }); + if (setupStatus.nonFatalPackageUpgradeErrors.length > 0) { + body.nonFatalPackageUpgradeErrors = setupStatus.nonFatalPackageUpgradeErrors; + } return response.ok({ body, diff --git a/x-pack/plugins/fleet/server/routes/setup/index.ts b/x-pack/plugins/fleet/server/routes/setup/index.ts index a40e7138e0f95..d64c9f24f2610 100644 --- a/x-pack/plugins/fleet/server/routes/setup/index.ts +++ b/x-pack/plugins/fleet/server/routes/setup/index.ts @@ -9,9 +9,8 @@ import type { IRouter } from 'src/core/server'; import { PLUGIN_ID, AGENTS_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; import type { FleetConfigType } from '../../../common'; -import { PostFleetSetupRequestSchema } from '../../types'; -import { getFleetStatusHandler, fleetSetupHandler, fleetAgentSetupHandler } from './handlers'; +import { getFleetStatusHandler, fleetSetupHandler } from './handlers'; export const registerFleetSetupRoute = (router: IRouter) => { router.post( @@ -26,14 +25,15 @@ export const registerFleetSetupRoute = (router: IRouter) => { ); }; +// That route is used by agent to setup Fleet export const registerCreateFleetSetupRoute = (router: IRouter) => { router.post( { path: AGENTS_SETUP_API_ROUTES.CREATE_PATTERN, - validate: PostFleetSetupRequestSchema, + validate: false, options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - fleetAgentSetupHandler + fleetSetupHandler ); }; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 58ec3972ca517..f55de4b691999 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -61,9 +61,7 @@ const getSavedObjectTypes = ( properties: { fleet_server_hosts: { type: 'keyword' }, has_seen_add_data_notice: { type: 'boolean', index: false }, - // TODO remove as part of https://github.com/elastic/kibana/issues/94303 - kibana_urls: { type: 'keyword' }, - kibana_ca_sha256: { type: 'keyword' }, + has_seen_fleet_migration_notice: { type: 'boolean', index: false }, }, }, migrations: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_13_0.test.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_13_0.test.ts index 6897efbe94110..75e2922bd5149 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_13_0.test.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_13_0.test.ts @@ -12,8 +12,6 @@ import type { PackagePolicy } from '../../../../common'; import { migrationMocks } from '../../../../../../../src/core/server/mocks'; -import { appContextService } from '../../../services'; - import { migrateEndpointPackagePolicyToV7130 } from './to_v7_13_0'; describe('7.13.0 Endpoint Package Policy migration', () => { @@ -128,16 +126,6 @@ describe('7.13.0 Endpoint Package Policy migration', () => { const migrationContext = migrationMocks.createContext(); - beforeEach(() => { - // set `fleetServerEnabled` flag to true - appContextService.fleetServerEnabled = true; - }); - - afterEach(() => { - // set `fleetServerEnabled` flag back to false - appContextService.fleetServerEnabled = false; - }); - it('should adjust the relative url for all artifact manifests', () => { expect( migrateEndpointPackagePolicyToV7130(createOldPackagePolicySO(), migrationContext) @@ -154,15 +142,4 @@ describe('7.13.0 Endpoint Package Policy migration', () => { unchangedPackagePolicySo ); }); - - it('should NOT migrate artifact relative_url if fleetServerEnabled is false', () => { - const packagePolicySo = createOldPackagePolicySO(); - const unchangedPackagePolicySo = cloneDeep(packagePolicySo); - - appContextService.fleetServerEnabled = false; - - expect(migrateEndpointPackagePolicyToV7130(packagePolicySo, migrationContext)).toEqual( - unchangedPackagePolicySo - ); - }); }); diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_13_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_13_0.ts index 5eb0c7a6e3141..655ce37b4faaf 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_13_0.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_13_0.ts @@ -10,7 +10,6 @@ import type { SavedObjectMigrationFn } from 'kibana/server'; import type { PackagePolicy } from '../../../../common'; import { relativeDownloadUrlFromArtifact } from '../../../services/artifacts/mappings'; import type { ArtifactElasticsearchProperties } from '../../../services'; -import { appContextService } from '../../../services'; type ArtifactManifestList = Record< string, @@ -22,19 +21,16 @@ export const migrateEndpointPackagePolicyToV7130: SavedObjectMigrationFn< PackagePolicy > = (packagePolicyDoc) => { if (packagePolicyDoc.attributes.package?.name === 'endpoint') { - // Feature condition check here is temporary until v7.13 ships - if (appContextService.fleetServerEnabled) { - // Adjust all artifact URLs so that they point at fleet-server - const artifactList: ArtifactManifestList = - packagePolicyDoc.attributes?.inputs[0]?.config?.artifact_manifest.value.artifacts; + // Adjust all artifact URLs so that they point at fleet-server + const artifactList: ArtifactManifestList = + packagePolicyDoc.attributes?.inputs[0]?.config?.artifact_manifest.value.artifacts; - if (artifactList) { - for (const [identifier, artifactManifest] of Object.entries(artifactList)) { - artifactManifest.relative_url = relativeDownloadUrlFromArtifact({ - identifier, - decodedSha256: artifactManifest.decoded_sha256, - }); - } + if (artifactList) { + for (const [identifier, artifactManifest] of Object.entries(artifactList)) { + artifactManifest.relative_url = relativeDownloadUrlFromArtifact({ + identifier, + decodedSha256: artifactManifest.decoded_sha256, + }); } } } diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_10_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_10_0.ts index 97a5dd6e13eda..f3f6050a8cde2 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_10_0.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_10_0.ts @@ -91,6 +91,7 @@ export const migrateSettingsToV7100: SavedObjectMigrationFn< }, Settings > = (settingsDoc) => { + // @ts-expect-error settingsDoc.attributes.kibana_urls = [settingsDoc.attributes.kibana_url]; // @ts-expect-error delete settingsDoc.attributes.kibana_url; diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_13_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_13_0.ts index e4ba7ce56e847..8773bfd733420 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_13_0.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_13_0.ts @@ -17,6 +17,7 @@ export const migrateSettingsToV7130: SavedObjectMigrationFn< Settings & { package_auto_upgrade: string; agent_auto_upgrade: string; + kibana_urls: string; }, Settings > = (settingsDoc) => { @@ -24,6 +25,10 @@ export const migrateSettingsToV7130: SavedObjectMigrationFn< delete settingsDoc.attributes.package_auto_upgrade; // @ts-expect-error delete settingsDoc.attributes.agent_auto_upgrade; + // @ts-expect-error + delete settingsDoc.attributes.kibana_urls; + // @ts-expect-error + delete settingsDoc.attributes.kibana_ca_sha256; return settingsDoc; }; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 56e76130538cf..68bd9e721d714 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -28,7 +28,7 @@ function getSavedObjectMock(agentPolicyAttributes: any) { { id: '93f74c0-e876-11ea-b7d3-8b2acec6f75c', attributes: { - kibana_urls: ['http://localhost:5603'], + fleet_server_hosts: ['http://fleetserver:8220'], }, type: 'ingest_manager_settings', score: 1, @@ -171,11 +171,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { - hosts: ['http://localhost:5603'], - kibana: { - hosts: ['localhost:5603'], - protocol: 'http', - }, + hosts: ['http://fleetserver:8220'], }, agent: { monitoring: { @@ -207,11 +203,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { - hosts: ['http://localhost:5603'], - kibana: { - hosts: ['localhost:5603'], - protocol: 'http', - }, + hosts: ['http://fleetserver:8220'], }, agent: { monitoring: { @@ -244,11 +236,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { - hosts: ['http://localhost:5603'], - kibana: { - hosts: ['localhost:5603'], - protocol: 'http', - }, + hosts: ['http://fleetserver:8220'], }, agent: { monitoring: { diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 432e64280b74b..deb2da8dee553 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -51,10 +51,9 @@ import { AgentPolicyDeletionError, IngestManagerError, } from '../errors'; -import { getFullAgentPolicyKibanaConfig } from '../../common/services/full_agent_policy_kibana_config'; import { getPackageInfo } from './epm/packages'; -import { createAgentPolicyAction, getAgentsByKuery } from './agents'; +import { getAgentsByKuery } from './agents'; import { packagePolicyService } from './package_policy'; import { outputService } from './output'; import { agentPolicyUpdateEventHandler } from './agent_policy_update'; @@ -477,7 +476,7 @@ class AgentPolicyService { } if (oldAgentPolicy.is_managed && !options?.force) { - throw new IngestManagerError(`Cannot update integrations of managed policy ${id}`); + throw new IngestManagerError(`Cannot update integrations of hosted agent policy ${id}`); } return await this._update( @@ -508,7 +507,7 @@ class AgentPolicyService { } if (oldAgentPolicy.is_managed && !options?.force) { - throw new IngestManagerError(`Cannot remove integrations of managed policy ${id}`); + throw new IngestManagerError(`Cannot remove integrations of hosted agent policy ${id}`); } return await this._update( @@ -551,7 +550,7 @@ class AgentPolicyService { } if (agentPolicy.is_managed) { - throw new AgentPolicyDeletionError(`Cannot delete managed policy ${id}`); + throw new AgentPolicyDeletionError(`Cannot delete hosted agent policy ${id}`); } const { @@ -609,35 +608,6 @@ class AgentPolicyService { } await this.createFleetPolicyChangeFleetServer(soClient, esClient, agentPolicyId); - - return this.createFleetPolicyChangeActionSO(soClient, esClient, agentPolicyId); - } - - public async createFleetPolicyChangeActionSO( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agentPolicyId: string - ) { - const policy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId); - if (!policy || !policy.revision) { - return; - } - const packages = policy.inputs.reduce((acc, input) => { - const packageName = input.meta?.package?.name; - if (packageName && acc.indexOf(packageName) < 0) { - acc.push(packageName); - } - return acc; - }, []); - - await createAgentPolicyAction(soClient, esClient, { - type: 'POLICY_CHANGE', - data: { policy }, - ack_data: { packages }, - created_at: new Date().toISOString(), - policy_id: policy.id, - policy_revision: policy.revision, - }); } public async createFleetPolicyChangeFleetServer( @@ -775,7 +745,13 @@ class AgentPolicyService { cluster: ['monitor'], indices: [ { - names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + names: [ + 'logs-*', + 'metrics-*', + 'traces-*', + '.logs-endpoint.diagnostic.collection-*', + 'synthetics-*', + ], privileges: ['auto_configure', 'create_doc'], }, ], @@ -796,15 +772,6 @@ class AgentPolicyService { fullAgentPolicy.fleet = { hosts: settings.fleet_server_hosts, }; - } // TODO remove as part of https://github.com/elastic/kibana/issues/94303 - else { - if (!settings.kibana_urls || !settings.kibana_urls.length) - throw new Error('kibana_urls is missing'); - - fullAgentPolicy.fleet = { - hosts: settings.kibana_urls, - kibana: getFullAgentPolicyKibanaConfig(settings.kibana_urls), - }; } } return fullAgentPolicy; diff --git a/x-pack/plugins/fleet/server/services/agents/acks.test.ts b/x-pack/plugins/fleet/server/services/agents/acks.test.ts deleted file mode 100644 index 7342bbfe51e20..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/acks.test.ts +++ /dev/null @@ -1,382 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import Boom from '@hapi/boom'; -import type { SavedObjectsBulkResponse } from 'kibana/server'; -import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; - -import type { - Agent, - AgentActionSOAttributes, - BaseAgentActionSOAttributes, - AgentEvent, -} from '../../../common/types/models'; -import { AGENT_TYPE_PERMANENT, AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../../common/constants'; - -import { acknowledgeAgentActions } from './acks'; - -describe('test agent acks services', () => { - it('should succeed on valid and matched actions', async () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - mockSavedObjectsClient.bulkGet.mockReturnValue( - Promise.resolve({ - saved_objects: [ - { - id: 'action1', - references: [], - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - attributes: { - type: 'POLICY_CHANGE', - agent_id: 'id', - sent_at: '2020-03-14T19:45:02.620Z', - timestamp: '2019-01-04T14:32:03.36764-05:00', - created_at: '2020-03-14T19:45:02.620Z', - }, - }, - ], - } as SavedObjectsBulkResponse) - ); - - await acknowledgeAgentActions( - mockSavedObjectsClient, - mockElasticsearchClient, - ({ - id: 'id', - type: AGENT_TYPE_PERMANENT, - } as unknown) as Agent, - [ - { - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - action_id: 'action1', - agent_id: 'id', - } as AgentEvent, - ] - ); - }); - - it('should update config field on the agent if a policy change is acknowledged with an agent without policy', async () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - const actionAttributes = { - type: 'POLICY_CHANGE', - policy_id: 'policy1', - policy_revision: 4, - sent_at: '2020-03-14T19:45:02.620Z', - timestamp: '2019-01-04T14:32:03.36764-05:00', - created_at: '2020-03-14T19:45:02.620Z', - ack_data: JSON.stringify({ packages: ['system'] }), - }; - - mockSavedObjectsClient.bulkGet.mockReturnValue( - Promise.resolve({ - saved_objects: [ - { - id: 'action2', - references: [], - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - attributes: actionAttributes, - }, - ], - } as SavedObjectsBulkResponse) - ); - - await acknowledgeAgentActions( - mockSavedObjectsClient, - mockElasticsearchClient, - ({ - id: 'id', - type: AGENT_TYPE_PERMANENT, - policy_id: 'policy1', - } as unknown) as Agent, - [ - { - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - action_id: 'action2', - agent_id: 'id', - } as AgentEvent, - ] - ); - expect(mockSavedObjectsClient.bulkUpdate).not.toBeCalled(); - expect(mockElasticsearchClient.update).toBeCalled(); - expect(mockElasticsearchClient.update.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "packages": Array [ - "system", - ], - "policy_revision_idx": 4, - }, - }, - "id": "id", - "index": ".fleet-agents", - "refresh": "wait_for", - }, - ] - `); - }); - - it('should update config field on the agent if a policy change is acknowledged with a higher revision than the agent one', async () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - const actionAttributes = { - type: 'POLICY_CHANGE', - policy_id: 'policy1', - policy_revision: 4, - sent_at: '2020-03-14T19:45:02.620Z', - timestamp: '2019-01-04T14:32:03.36764-05:00', - created_at: '2020-03-14T19:45:02.620Z', - ack_data: JSON.stringify({ packages: ['system'] }), - }; - - mockSavedObjectsClient.bulkGet.mockReturnValue( - Promise.resolve({ - saved_objects: [ - { - id: 'action2', - references: [], - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - attributes: actionAttributes, - }, - ], - } as SavedObjectsBulkResponse) - ); - - await acknowledgeAgentActions( - mockSavedObjectsClient, - mockElasticsearchClient, - ({ - id: 'id', - type: AGENT_TYPE_PERMANENT, - policy_id: 'policy1', - policy_revision: 3, - } as unknown) as Agent, - [ - { - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - action_id: 'action2', - agent_id: 'id', - } as AgentEvent, - ] - ); - expect(mockSavedObjectsClient.bulkUpdate).not.toBeCalled(); - expect(mockElasticsearchClient.update).toBeCalled(); - expect(mockElasticsearchClient.update.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "body": Object { - "doc": Object { - "packages": Array [ - "system", - ], - "policy_revision_idx": 4, - }, - }, - "id": "id", - "index": ".fleet-agents", - "refresh": "wait_for", - }, - ] - `); - }); - - it('should not update config field on the agent if a policy change is acknowledged with a lower revision than the agent one', async () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - const actionAttributes = { - type: 'POLICY_CHANGE', - policy_id: 'policy1', - policy_revision: 4, - sent_at: '2020-03-14T19:45:02.620Z', - timestamp: '2019-01-04T14:32:03.36764-05:00', - created_at: '2020-03-14T19:45:02.620Z', - ack_data: JSON.stringify({ packages: ['system'] }), - }; - - mockSavedObjectsClient.bulkGet.mockReturnValue( - Promise.resolve({ - saved_objects: [ - { - id: 'action2', - references: [], - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - attributes: actionAttributes, - }, - ], - } as SavedObjectsBulkResponse) - ); - - await acknowledgeAgentActions( - mockSavedObjectsClient, - mockElasticsearchClient, - ({ - id: 'id', - type: AGENT_TYPE_PERMANENT, - policy_id: 'policy1', - policy_revision: 5, - } as unknown) as Agent, - [ - { - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - action_id: 'action2', - agent_id: 'id', - } as AgentEvent, - ] - ); - expect(mockSavedObjectsClient.bulkUpdate).not.toBeCalled(); - expect(mockSavedObjectsClient.update).not.toBeCalled(); - }); - - it('should not update config field on the agent if a policy change for an old revision is acknowledged', async () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - mockSavedObjectsClient.bulkGet.mockReturnValue( - Promise.resolve({ - saved_objects: [ - { - id: 'action3', - references: [], - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - attributes: { - type: 'POLICY_CHANGE', - sent_at: '2020-03-14T19:45:02.620Z', - timestamp: '2019-01-04T14:32:03.36764-05:00', - created_at: '2020-03-14T19:45:02.620Z', - policy_id: 'policy1', - policy_revision: 99, - }, - }, - ], - } as SavedObjectsBulkResponse) - ); - - await acknowledgeAgentActions( - mockSavedObjectsClient, - mockElasticsearchClient, - ({ - id: 'id', - type: AGENT_TYPE_PERMANENT, - policy_id: 'policy1', - policy_revision: 100, - } as unknown) as Agent, - [ - { - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - action_id: 'action3', - agent_id: 'id', - } as AgentEvent, - ] - ); - expect(mockSavedObjectsClient.bulkUpdate).not.toBeCalled(); - expect(mockSavedObjectsClient.update).not.toBeCalled(); - }); - - it('should fail for actions that cannot be found on agent actions list', async () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - mockSavedObjectsClient.bulkGet.mockReturnValue( - Promise.resolve({ - saved_objects: [ - { - id: 'action4', - error: { - message: 'Not found', - statusCode: 404, - }, - }, - ], - } as SavedObjectsBulkResponse) - ); - - try { - await acknowledgeAgentActions( - mockSavedObjectsClient, - mockElasticsearchClient, - ({ - id: 'id', - type: AGENT_TYPE_PERMANENT, - } as unknown) as Agent, - [ - ({ - type: 'ACTION_RESULT', - subtype: 'CONFIG', - timestamp: '2019-01-04T14:32:03.36764-05:00', - action_id: 'action4', - agent_id: 'id', - } as unknown) as AgentEvent, - ] - ); - expect(true).toBeFalsy(); - } catch (e) { - expect(Boom.isBoom(e)).toBeTruthy(); - } - }); - - it('should fail for events that have types not in the allowed acknowledgement type list', async () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - mockSavedObjectsClient.bulkGet.mockReturnValue( - Promise.resolve({ - saved_objects: [ - { - id: 'action5', - references: [], - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - attributes: { - type: 'POLICY_CHANGE', - agent_id: 'id', - sent_at: '2020-03-14T19:45:02.620Z', - timestamp: '2019-01-04T14:32:03.36764-05:00', - created_at: '2020-03-14T19:45:02.620Z', - }, - }, - ], - } as SavedObjectsBulkResponse) - ); - - try { - await acknowledgeAgentActions( - mockSavedObjectsClient, - mockElasticsearchClient, - ({ - id: 'id', - type: AGENT_TYPE_PERMANENT, - } as unknown) as Agent, - [ - ({ - type: 'ACTION', - subtype: 'FAILED', - timestamp: '2019-01-04T14:32:03.36764-05:00', - action_id: 'action5', - agent_id: 'id', - } as unknown) as AgentEvent, - ] - ); - expect(true).toBeFalsy(); - } catch (e) { - expect(Boom.isBoom(e)).toBeTruthy(); - } - }); -}); diff --git a/x-pack/plugins/fleet/server/services/agents/acks.ts b/x-pack/plugins/fleet/server/services/agents/acks.ts deleted file mode 100644 index 3acdfc2708eab..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/acks.ts +++ /dev/null @@ -1,216 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { KibanaRequest } from 'src/core/server'; -import type { - ElasticsearchClient, - SavedObjectsBulkCreateObject, - SavedObjectsBulkResponse, - SavedObjectsClientContract, -} from 'src/core/server'; -import Boom from '@hapi/boom'; -import LRU from 'lru-cache'; - -import type { - Agent, - AgentAction, - AgentPolicyAction, - AgentPolicyActionV7_9, - AgentEvent, - AgentEventSOAttributes, - AgentSOAttributes, - AgentActionSOAttributes, -} from '../../types'; -import { AGENT_EVENT_SAVED_OBJECT_TYPE, AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../constants'; - -import { getAgentActionByIds } from './actions'; -import { forceUnenrollAgent } from './unenroll'; -import { ackAgentUpgraded } from './upgrade'; -import { updateAgent } from './crud'; - -const ALLOWED_ACKNOWLEDGEMENT_TYPE: string[] = ['ACTION_RESULT']; - -const actionCache = new LRU({ - max: 20, - maxAge: 10 * 60 * 1000, // 10 minutes -}); - -export async function acknowledgeAgentActions( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agent: Agent, - agentEvents: AgentEvent[] -): Promise { - if (agentEvents.length === 0) { - return []; - } - - for (const agentEvent of agentEvents) { - if (!isAllowedType(agentEvent.type)) { - throw Boom.badRequest(`${agentEvent.type} not allowed for acknowledgment only ACTION_RESULT`); - } - } - - const actionIds = agentEvents - .map((event) => event.action_id) - .filter((actionId) => actionId !== undefined) as string[]; - - let actions: AgentAction[]; - try { - actions = await fetchActionsUsingCache(soClient, actionIds); - } catch (error) { - if (Boom.isBoom(error) && error.output.statusCode === 404) { - throw Boom.badRequest(`One or more actions cannot be found`); - } - throw error; - } - - const agentActionsIds: string[] = []; - for (const action of actions) { - if (action.agent_id) { - agentActionsIds.push(action.id); - } - if (action.agent_id && action.agent_id !== agent.id) { - throw Boom.badRequest(`${action.id} not found`); - } - } - - const isAgentUnenrolled = actions.some((action) => action.type === 'UNENROLL'); - if (isAgentUnenrolled) { - await forceUnenrollAgent(soClient, esClient, agent); - } - - const upgradeAction = actions.find((action) => action.type === 'UPGRADE'); - if (upgradeAction) { - await ackAgentUpgraded(soClient, esClient, upgradeAction); - } - - const configChangeAction = getLatestConfigChangePolicyActionIfUpdated(agent, actions); - - if (configChangeAction) { - await updateAgent(esClient, agent.id, { - policy_revision: configChangeAction.policy_revision, - packages: configChangeAction?.ack_data?.packages, - }); - } - - if (agentActionsIds.length > 0) { - await soClient.bulkUpdate([ - ...buildUpdateAgentActionSentAt(agentActionsIds), - ]); - } - - return actions; -} - -async function fetchActionsUsingCache( - soClient: SavedObjectsClientContract, - actionIds: string[] -): Promise { - const missingActionIds: string[] = []; - const actions = actionIds - .map((actionId) => { - const action = actionCache.get(actionId); - if (!action) { - missingActionIds.push(actionId); - } - return action; - }) - .filter((action): action is AgentAction => action !== undefined); - - if (missingActionIds.length === 0) { - return actions; - } - - const freshActions = await getAgentActionByIds(soClient, actionIds, false); - freshActions.forEach((action) => actionCache.set(action.id, action)); - - return [...freshActions, ...actions]; -} - -function isAgentPolicyAction( - action: AgentAction | AgentPolicyAction | AgentPolicyActionV7_9 -): action is AgentPolicyAction | AgentPolicyActionV7_9 { - return (action as AgentPolicyAction).policy_id !== undefined; -} - -function getLatestConfigChangePolicyActionIfUpdated( - agent: Agent, - actions: Array -): AgentPolicyAction | AgentPolicyActionV7_9 | null { - return actions.reduce((acc, action) => { - if ( - !isAgentPolicyAction(action) || - (action.type !== 'POLICY_CHANGE' && action.type !== 'CONFIG_CHANGE') || - action.policy_id !== agent.policy_id || - (action?.policy_revision ?? 0) < (agent.policy_revision || 0) - ) { - return acc; - } - - return action; - }, null); -} - -function buildUpdateAgentActionSentAt( - actionsIds: string[], - sentAt: string = new Date().toISOString() -) { - return actionsIds.map((actionId) => ({ - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - id: actionId, - attributes: { - sent_at: sentAt, - }, - })); -} - -function isAllowedType(eventType: string): boolean { - return ALLOWED_ACKNOWLEDGEMENT_TYPE.indexOf(eventType) >= 0; -} - -export async function saveAgentEvents( - soClient: SavedObjectsClientContract, - events: AgentEvent[] -): Promise> { - const objects: Array> = events.map( - (eventData) => { - return { - attributes: { - ...eventData, - payload: eventData.payload ? JSON.stringify(eventData.payload) : undefined, - }, - type: AGENT_EVENT_SAVED_OBJECT_TYPE, - }; - } - ); - - return await soClient.bulkCreate(objects); -} - -export interface AcksService { - acknowledgeAgentActions: ( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agent: Agent, - actionIds: AgentEvent[] - ) => Promise; - - authenticateAgentWithAccessToken: ( - esClient: ElasticsearchClient, - request: KibanaRequest - ) => Promise; - - getSavedObjectsClientContract: (kibanaRequest: KibanaRequest) => SavedObjectsClientContract; - - getElasticsearchClientContract: () => ElasticsearchClient; - - saveAgentEvents: ( - soClient: SavedObjectsClientContract, - events: AgentEvent[] - ) => Promise>; -} diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts deleted file mode 100644 index 1af6173f938d6..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/actions.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { SavedObject } from 'kibana/server'; - -import { savedObjectsClientMock, elasticsearchServiceMock } from 'src/core/server/mocks'; - -import type { AgentAction } from '../../../common/types/models'; - -import { createAgentAction } from './actions'; - -describe('test agent actions services', () => { - it('should create a new action', async () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockedEsClient = elasticsearchServiceMock.createInternalClient(); - const newAgentAction: Omit = { - agent_id: 'agentid', - type: 'POLICY_CHANGE', - data: { content: 'data' }, - sent_at: '2020-03-14T19:45:02.620Z', - created_at: '2020-03-14T19:45:02.620Z', - }; - mockSavedObjectsClient.create.mockReturnValue( - Promise.resolve({ - attributes: { - agent_id: 'agentid', - type: 'POLICY_CHANGE', - data: JSON.stringify({ content: 'data' }), - sent_at: '2020-03-14T19:45:02.620Z', - created_at: '2020-03-14T19:45:02.620Z', - }, - } as SavedObject) - ); - await createAgentAction(mockSavedObjectsClient, mockedEsClient, newAgentAction); - - const createdAction = (mockSavedObjectsClient.create.mock - .calls[0][1] as unknown) as AgentAction; - expect(createdAction).toBeDefined(); - expect(createdAction?.type).toEqual(newAgentAction.type); - expect(createdAction?.data).toEqual(JSON.stringify(newAgentAction.data)); - expect(createdAction?.sent_at).toEqual(newAgentAction.sent_at); - }); -}); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index bcece7283270b..dbd7105fd5607 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -5,373 +5,87 @@ * 2.0. */ -import type { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; +import uuid from 'uuid'; +import type { ElasticsearchClient } from 'kibana/server'; -import type { - Agent, - AgentAction, - AgentPolicyAction, - BaseAgentActionSOAttributes, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, - FleetServerAgentAction, -} from '../../../common/types/models'; -import { AGENT_ACTION_SAVED_OBJECT_TYPE, AGENT_ACTIONS_INDEX } from '../../../common/constants'; -import { appContextService } from '../app_context'; -import { nodeTypes } from '../../../../../../src/plugins/data/common'; - -import { - isAgentActionSavedObject, - isPolicyActionSavedObject, - savedObjectToAgentAction, -} from './saved_objects'; +import type { Agent, AgentAction, FleetServerAgentAction } from '../../../common/types/models'; +import { AGENT_ACTIONS_INDEX } from '../../../common/constants'; const ONE_MONTH_IN_MS = 2592000000; export async function createAgentAction( - soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, newAgentAction: Omit ): Promise { - return createAction(soClient, esClient, newAgentAction); + const id = uuid.v4(); + const body: FleetServerAgentAction = { + '@timestamp': new Date().toISOString(), + expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), + agents: [newAgentAction.agent_id], + action_id: id, + data: newAgentAction.data, + type: newAgentAction.type, + }; + + await esClient.create({ + index: AGENT_ACTIONS_INDEX, + id, + body, + refresh: 'wait_for', + }); + + return { + id, + ...newAgentAction, + }; } export async function bulkCreateAgentActions( - soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, newAgentActions: Array> ): Promise { - return bulkCreateActions(soClient, esClient, newAgentActions); -} - -export function createAgentPolicyAction( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - newAgentAction: Omit -): Promise { - return createAction(soClient, esClient, newAgentAction); -} - -async function createAction( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - newAgentAction: Omit -): Promise; -async function createAction( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - newAgentAction: Omit -): Promise; -async function createAction( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - newAgentAction: Omit | Omit -): Promise { - const actionSO = await soClient.create( - AGENT_ACTION_SAVED_OBJECT_TYPE, - { + const actions = newAgentActions.map((newAgentAction) => { + const id = uuid.v4(); + return { + id, ...newAgentAction, - data: newAgentAction.data ? JSON.stringify(newAgentAction.data) : undefined, - ack_data: newAgentAction.ack_data ? JSON.stringify(newAgentAction.ack_data) : undefined, - } - ); - - if (isAgentActionSavedObject(actionSO)) { - const body: FleetServerAgentAction = { - '@timestamp': new Date().toISOString(), - expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [actionSO.attributes.agent_id], - action_id: actionSO.id, - data: newAgentAction.data, - type: newAgentAction.type, }; - - await esClient.create({ - index: AGENT_ACTIONS_INDEX, - id: actionSO.id, - body, - refresh: 'wait_for', - }); - } - - if (isAgentActionSavedObject(actionSO)) { - const agentAction = savedObjectToAgentAction(actionSO); - // Action `data` is encrypted, so is not returned from the saved object - // so we add back the original value from the request to form the expected - // response shape for POST create agent action endpoint - agentAction.data = newAgentAction.data; - - return agentAction; - } else if (isPolicyActionSavedObject(actionSO)) { - const agentAction = savedObjectToAgentAction(actionSO); - agentAction.data = newAgentAction.data; - - return agentAction; - } - throw new Error('Invalid action'); -} - -async function bulkCreateActions( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - newAgentActions: Array> -): Promise; -async function bulkCreateActions( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - newAgentActions: Array> -): Promise; -async function bulkCreateActions( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - newAgentActions: Array | Omit> -): Promise> { - const { saved_objects: actionSOs } = await soClient.bulkCreate( - newAgentActions.map((newAgentAction) => ({ - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - attributes: { - ...newAgentAction, - data: newAgentAction.data ? JSON.stringify(newAgentAction.data) : undefined, - ack_data: newAgentAction.ack_data ? JSON.stringify(newAgentAction.ack_data) : undefined, - }, - })) - ); - - if (actionSOs.length > 0) { - await esClient.bulk({ - index: AGENT_ACTIONS_INDEX, - body: actionSOs.flatMap((actionSO) => { - if (!isAgentActionSavedObject(actionSO)) { - return []; - } - const body: FleetServerAgentAction = { - '@timestamp': new Date().toISOString(), - expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [actionSO.attributes.agent_id], - action_id: actionSO.id, - data: actionSO.attributes.data ? JSON.parse(actionSO.attributes.data) : undefined, - type: actionSO.type, - }; - - return [ - { - create: { - _id: actionSO.id, - }, - }, - body, - ]; - }), - }); - } - - return actionSOs.map((actionSO) => { - if (isAgentActionSavedObject(actionSO)) { - const agentAction = savedObjectToAgentAction(actionSO); - // Compared to single create (createAction()), we don't add back the - // original value of `agentAction.data` as this method isn't exposed - // via an HTTP endpoint - return agentAction; - } else if (isPolicyActionSavedObject(actionSO)) { - const agentAction = savedObjectToAgentAction(actionSO); - return agentAction; - } - throw new Error('Invalid action'); - }); -} - -export async function getAgentActionsForCheckin( - soClient: SavedObjectsClientContract, - agentId: string -): Promise { - const filter = nodeTypes.function.buildNode('and', [ - nodeTypes.function.buildNode( - 'not', - nodeTypes.function.buildNodeWithArgumentNodes('is', [ - nodeTypes.literal.buildNode(`${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.sent_at`), - nodeTypes.wildcard.buildNode(nodeTypes.wildcard.wildcardSymbol), - nodeTypes.literal.buildNode(false), - ]) - ), - nodeTypes.function.buildNode( - 'not', - nodeTypes.function.buildNodeWithArgumentNodes('is', [ - nodeTypes.literal.buildNode(`${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.type`), - nodeTypes.literal.buildNode('INTERNAL_POLICY_REASSIGN'), - nodeTypes.literal.buildNode(false), - ]) - ), - nodeTypes.function.buildNodeWithArgumentNodes('is', [ - nodeTypes.literal.buildNode(`${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.agent_id`), - nodeTypes.literal.buildNode(agentId), - nodeTypes.literal.buildNode(false), - ]), - ]); - - const res = await soClient.find({ - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - filter, }); - return Promise.all( - res.saved_objects.map(async (so) => { - // Get decrypted actions - return savedObjectToAgentAction( - await appContextService - .getEncryptedSavedObjects() - .getDecryptedAsInternalUser( - AGENT_ACTION_SAVED_OBJECT_TYPE, - so.id - ) - ); - }) - ); -} - -export async function getAgentActionByIds( - soClient: SavedObjectsClientContract, - actionIds: string[], - decryptData: boolean = true -) { - const actions = ( - await soClient.bulkGet( - actionIds.map((actionId) => ({ - id: actionId, - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - })) - ) - ).saved_objects.map((action) => savedObjectToAgentAction(action)); - - if (!decryptData) { + if (actions.length === 0) { return actions; } - return Promise.all( - actions.map(async (action) => { - // Get decrypted actions - return savedObjectToAgentAction( - await appContextService - .getEncryptedSavedObjects() - .getDecryptedAsInternalUser( - AGENT_ACTION_SAVED_OBJECT_TYPE, - action.id - ) - ); - }) - ); -} - -export async function getAgentPolicyActionByIds( - soClient: SavedObjectsClientContract, - actionIds: string[], - decryptData: boolean = true -) { - const actions = ( - await soClient.bulkGet( - actionIds.map((actionId) => ({ - id: actionId, - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - })) - ) - ).saved_objects.map((action) => savedObjectToAgentAction(action)); - - if (!decryptData) { - return actions; - } - - return Promise.all( - actions.map(async (action) => { - // Get decrypted actions - return savedObjectToAgentAction( - await appContextService - .getEncryptedSavedObjects() - .getDecryptedAsInternalUser( - AGENT_ACTION_SAVED_OBJECT_TYPE, - action.id - ) - ); - }) - ); -} - -export async function getNewActionsSince( - soClient: SavedObjectsClientContract, - timestamp: string, - decryptData: boolean = true -) { - const filter = nodeTypes.function.buildNode('and', [ - nodeTypes.function.buildNode( - 'not', - nodeTypes.function.buildNodeWithArgumentNodes('is', [ - nodeTypes.literal.buildNode(`${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.sent_at`), - nodeTypes.wildcard.buildNode(nodeTypes.wildcard.wildcardSymbol), - nodeTypes.literal.buildNode(false), - ]) - ), - nodeTypes.function.buildNodeWithArgumentNodes('is', [ - nodeTypes.literal.buildNode(`${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.agent_id`), - nodeTypes.wildcard.buildNode(nodeTypes.wildcard.wildcardSymbol), - nodeTypes.literal.buildNode(false), - ]), - nodeTypes.function.buildNode( - 'range', - `${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.created_at`, - { - gt: timestamp, - } - ), - ]); - - const actions = ( - await soClient.find({ - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - filter, - }) - ).saved_objects - .filter(isAgentActionSavedObject) - .map((so) => savedObjectToAgentAction(so)); - - if (!decryptData) { - return actions; - } - - return await Promise.all( - actions.map(async (action) => { - // Get decrypted actions - return savedObjectToAgentAction( - await appContextService - .getEncryptedSavedObjects() - .getDecryptedAsInternalUser( - AGENT_ACTION_SAVED_OBJECT_TYPE, - action.id - ) - ); - }) - ); -} - -export async function getLatestConfigChangeAction( - soClient: SavedObjectsClientContract, - policyId: string -) { - const res = await soClient.find({ - type: AGENT_ACTION_SAVED_OBJECT_TYPE, - search: policyId, - searchFields: ['policy_id'], - sortField: 'created_at', - sortOrder: 'desc', + await esClient.bulk({ + index: AGENT_ACTIONS_INDEX, + body: actions.flatMap((action) => { + const body: FleetServerAgentAction = { + '@timestamp': new Date().toISOString(), + expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), + agents: [action.agent_id], + action_id: action.id, + data: action.data, + type: action.type, + }; + + return [ + { + create: { + _id: action.id, + }, + }, + body, + ]; + }), }); - if (res.saved_objects[0]) { - return savedObjectToAgentAction(res.saved_objects[0]); - } + return actions; } export interface ActionsService { getAgent: (esClient: ElasticsearchClient, agentId: string) => Promise; createAgentAction: ( - soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, newAgentAction: Omit ) => Promise; diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/index.ts b/x-pack/plugins/fleet/server/services/agents/checkin/index.ts deleted file mode 100644 index ce81d6b366e9a..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/checkin/index.ts +++ /dev/null @@ -1,118 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import deepEqual from 'fast-deep-equal'; -import type { - ElasticsearchClient, - SavedObjectsClientContract, - SavedObjectsBulkCreateObject, -} from 'src/core/server'; - -import type { - Agent, - NewAgentEvent, - AgentEvent, - AgentSOAttributes, - AgentEventSOAttributes, -} from '../../../types'; -import { AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../../constants'; -import { getAgentActionsForCheckin } from '../actions'; -import { updateAgent } from '../crud'; - -import { agentCheckinState } from './state'; - -export async function agentCheckin( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agent: Agent, - data: { - events: NewAgentEvent[]; - localMetadata?: any; - status?: 'online' | 'error' | 'degraded'; - }, - options?: { signal: AbortSignal } -) { - const updateData: Partial = {}; - await processEventsForCheckin(soClient, agent, data.events); - if (data.localMetadata && !deepEqual(data.localMetadata, agent.local_metadata)) { - updateData.local_metadata = data.localMetadata; - } - if (data.status !== agent.last_checkin_status) { - updateData.last_checkin_status = data.status; - } - // Update agent only if something changed - if (Object.keys(updateData).length > 0) { - await updateAgent(esClient, agent.id, updateData); - } - // Check if some actions are not acknowledged - let actions = await getAgentActionsForCheckin(soClient, agent.id); - if (actions.length > 0) { - return { actions }; - } - - // Wait for new actions - actions = await agentCheckinState.subscribeToNewActions(soClient, esClient, agent, options); - - return { actions }; -} - -async function processEventsForCheckin( - soClient: SavedObjectsClientContract, - agent: Agent, - events: NewAgentEvent[] -) { - const updatedErrorEvents: Array = [...agent.current_error_events]; - for (const event of events) { - // @ts-ignore - event.policy_id = agent.policy_id; - - if (isErrorOrState(event)) { - // Remove any global or specific to a stream event - const existingEventIndex = updatedErrorEvents.findIndex( - (e) => e.stream_id === event.stream_id - ); - if (existingEventIndex >= 0) { - updatedErrorEvents.splice(existingEventIndex, 1); - } - if (event.type === 'ERROR') { - updatedErrorEvents.push(event); - } - } - } - - if (events.length > 0) { - await createEventsForAgent(soClient, agent.id, events); - } - - return { - updatedErrorEvents, - }; -} - -async function createEventsForAgent( - soClient: SavedObjectsClientContract, - agentId: string, - events: NewAgentEvent[] -) { - const objects: Array> = events.map( - (eventData) => { - return { - attributes: { - ...eventData, - payload: eventData.payload ? JSON.stringify(eventData.payload) : undefined, - }, - type: AGENT_EVENT_SAVED_OBJECT_TYPE, - }; - } - ); - - return soClient.bulkCreate(objects); -} - -function isErrorOrState(event: AgentEvent | NewAgentEvent) { - return event.type === 'STATE' || event.type === 'ERROR'; -} diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/rxjs_utils.test.ts b/x-pack/plugins/fleet/server/services/agents/checkin/rxjs_utils.test.ts deleted file mode 100644 index 18f788b087250..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/checkin/rxjs_utils.test.ts +++ /dev/null @@ -1,30 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { TestScheduler } from 'rxjs/testing'; - -import { createRateLimiter } from './rxjs_utils'; - -describe('createRateLimiter', () => { - it('should rate limit correctly with 1 request per 10ms', async () => { - const scheduler = new TestScheduler((actual, expected) => { - expect(actual).toEqual(expected); - }); - - scheduler.run(({ expectObservable, cold }) => { - const source = cold('a-b-c-d-e-f|'); - const intervalMs = 10; - const perInterval = 1; - const maxDelayMs = 50; - const rateLimiter = createRateLimiter(intervalMs, perInterval, maxDelayMs, scheduler); - const obs = source.pipe(rateLimiter()); - // f should be dropped because of maxDelay - const results = 'a 9ms b 9ms c 9ms d 9ms (e|)'; - expectObservable(obs).toBe(results); - }); - }); -}); diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/rxjs_utils.ts b/x-pack/plugins/fleet/server/services/agents/checkin/rxjs_utils.ts deleted file mode 100644 index aec67cf8908dd..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/checkin/rxjs_utils.ts +++ /dev/null @@ -1,83 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as Rx from 'rxjs'; -import { concatMap, delay } from 'rxjs/operators'; - -export class AbortError extends Error {} - -export const toPromiseAbortable = ( - observable: Rx.Observable, - signal?: AbortSignal -): Promise => - new Promise((resolve, reject) => { - if (signal && signal.aborted) { - reject(new AbortError('Aborted')); - return; - } - - const listener = () => { - subscription.unsubscribe(); - reject(new AbortError('Aborted')); - }; - const cleanup = () => { - if (signal) { - signal.removeEventListener('abort', listener); - } - }; - const subscription = observable.subscribe( - (data) => { - cleanup(); - resolve(data); - }, - (err) => { - cleanup(); - reject(err); - } - ); - - if (signal) { - signal.addEventListener('abort', listener, { once: true }); - } - }); - -export function createRateLimiter( - ratelimitIntervalMs: number, - ratelimitRequestPerInterval: number, - maxDelay: number, - scheduler = Rx.asyncScheduler -) { - let intervalEnd = 0; - let countInCurrentInterval = 0; - - function createRateLimitOperator(): Rx.OperatorFunction { - const maxIntervalEnd = scheduler.now() + maxDelay; - - return Rx.pipe( - concatMap(function rateLimit(value: T) { - const now = scheduler.now(); - if (intervalEnd <= now) { - countInCurrentInterval = 1; - intervalEnd = now + ratelimitIntervalMs; - return Rx.of(value); - } else if (intervalEnd >= maxIntervalEnd) { - // drop the value as it's never going to success as long polling timeout is going to happen before we can send the policy - return Rx.EMPTY; - } else { - if (++countInCurrentInterval > ratelimitRequestPerInterval) { - countInCurrentInterval = 1; - intervalEnd += ratelimitIntervalMs; - } - - const wait = intervalEnd - ratelimitIntervalMs - now; - return wait > 0 ? Rx.of(value).pipe(delay(wait, scheduler)) : Rx.of(value); - } - }) - ); - } - return createRateLimitOperator; -} diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state.ts deleted file mode 100644 index c48e0380da2c4..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state.ts +++ /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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; - -import type { Agent } from '../../../types'; -import { appContextService } from '../../app_context'; -import { AGENT_UPDATE_LAST_CHECKIN_INTERVAL_MS } from '../../../constants'; - -import { agentCheckinStateConnectedAgentsFactory } from './state_connected_agents'; -import { agentCheckinStateNewActionsFactory } from './state_new_actions'; - -function agentCheckinStateFactory() { - const agentConnected = agentCheckinStateConnectedAgentsFactory(); - let newActions: ReturnType; - let interval: NodeJS.Timeout; - - function start() { - newActions = agentCheckinStateNewActionsFactory(); - interval = setInterval(async () => { - try { - await agentConnected.updateLastCheckinAt(); - } catch (err) { - appContextService.getLogger().error(err); - } - }, AGENT_UPDATE_LAST_CHECKIN_INTERVAL_MS); - } - - function stop() { - if (interval) { - clearInterval(interval); - } - } - return { - subscribeToNewActions: async ( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agent: Agent, - options?: { signal: AbortSignal } - ) => { - if (!newActions) { - throw new Error('Agent checkin state not initialized'); - } - - return agentConnected.wrapPromise( - agent.id, - newActions.subscribeToNewActions(soClient, esClient, agent, options) - ); - }, - start, - stop, - }; -} - -export const agentCheckinState = agentCheckinStateFactory(); diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_connected_agents.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_connected_agents.ts deleted file mode 100644 index f8ef33acb30f1..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_connected_agents.ts +++ /dev/null @@ -1,56 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { appContextService } from '../../app_context'; -import { bulkUpdateAgents } from '../crud'; - -export function agentCheckinStateConnectedAgentsFactory() { - const connectedAgentsIds = new Set(); - let agentToUpdate = new Set(); - - function addAgent(agentId: string) { - connectedAgentsIds.add(agentId); - agentToUpdate.add(agentId); - } - - function removeAgent(agentId: string) { - connectedAgentsIds.delete(agentId); - } - - async function wrapPromise(agentId: string, p: Promise): Promise { - try { - addAgent(agentId); - const res = await p; - removeAgent(agentId); - return res; - } catch (err) { - removeAgent(agentId); - throw err; - } - } - - async function updateLastCheckinAt() { - if (agentToUpdate.size === 0) { - return; - } - const esClient = appContextService.getInternalUserESClient(); - const now = new Date().toISOString(); - const updates = [...agentToUpdate.values()].map((agentId) => ({ - agentId, - data: { - last_checkin: now, - }, - })); - agentToUpdate = new Set([...connectedAgentsIds.values()]); - await bulkUpdateAgents(esClient, updates); - } - - return { - wrapPromise, - updateLastCheckinAt, - }; -} diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts deleted file mode 100644 index 12205f3110614..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts +++ /dev/null @@ -1,276 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ElasticsearchClient } from 'kibana/server'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; -import { take } from 'rxjs/operators'; - -import { getNewActionsSince } from '../actions'; -import type { Agent, AgentAction, AgentPolicyAction } from '../../../types'; -import { outputType } from '../../../../common/constants'; - -import { - createAgentActionFromPolicyAction, - createNewActionsSharedObservable, -} from './state_new_actions'; - -jest.mock('../../app_context', () => ({ - appContextService: { - getConfig: () => ({}), - getInternalUserSOClient: () => { - return {}; - }, - getEncryptedSavedObjects: () => ({ - getDecryptedAsInternalUser: () => ({ - attributes: { - default_api_key: 'MOCK_API_KEY', - }, - }), - }), - }, -})); - -jest.mock('../actions'); - -jest.useFakeTimers(); - -function waitForPromiseResolved() { - return new Promise((resolve) => setImmediate(resolve)); -} - -function getMockedNewActionSince() { - return getNewActionsSince as jest.MockedFunction; -} - -const mockedEsClient = {} as ElasticsearchClient; - -describe('test agent checkin new action services', () => { - describe('newAgetActionObservable', () => { - beforeEach(() => { - (getNewActionsSince as jest.MockedFunction).mockReset(); - }); - it('should work, call get actions until there is new action', async () => { - const observable = createNewActionsSharedObservable(); - - getMockedNewActionSince() - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - ({ id: 'action1', created_at: new Date().toISOString() } as unknown) as AgentAction, - ]) - .mockResolvedValueOnce([ - ({ id: 'action2', created_at: new Date().toISOString() } as unknown) as AgentAction, - ]); - // First call - const promise = observable.pipe(take(1)).toPromise(); - - jest.advanceTimersByTime(5000); - await waitForPromiseResolved(); - jest.advanceTimersByTime(5000); - await waitForPromiseResolved(); - - const res = await promise; - expect(getNewActionsSince).toBeCalledTimes(2); - expect(res).toHaveLength(1); - expect(res[0].id).toBe('action1'); - // Second call - const secondSubscription = observable.pipe(take(1)).toPromise(); - - jest.advanceTimersByTime(5000); - await waitForPromiseResolved(); - - const secondRes = await secondSubscription; - expect(secondRes).toHaveLength(1); - expect(secondRes[0].id).toBe('action2'); - expect(getNewActionsSince).toBeCalledTimes(3); - // It should call getNewActionsSince with the last action returned - expect(getMockedNewActionSince().mock.calls[2][1]).toBe(res[0].created_at); - }); - - it('should not fetch actions concurrently', async () => { - const observable = createNewActionsSharedObservable(); - - const resolves: Array<(value?: any) => void> = []; - getMockedNewActionSince().mockImplementation(() => { - return new Promise((resolve) => { - resolves.push(resolve); - }); - }); - - observable.pipe(take(1)).toPromise(); - - jest.advanceTimersByTime(5000); - await waitForPromiseResolved(); - jest.advanceTimersByTime(5000); - await waitForPromiseResolved(); - jest.advanceTimersByTime(5000); - await waitForPromiseResolved(); - - expect(getNewActionsSince).toBeCalledTimes(1); - }); - }); - - describe('createAgentActionFromPolicyAction()', () => { - const mockSavedObjectsClient = savedObjectsClientMock.create(); - const mockAgent: Agent = { - id: 'agent1', - active: true, - type: 'PERMANENT', - local_metadata: { elastic: { agent: { version: '7.10.0' } } }, - user_provided_metadata: {}, - current_error_events: [], - packages: [], - enrolled_at: '2020-03-14T19:45:02.620Z', - default_api_key: 'MOCK_API_KEY', - }; - const mockPolicyAction: AgentPolicyAction = { - id: 'action1', - type: 'POLICY_CHANGE', - policy_id: 'policy1', - policy_revision: 1, - sent_at: '2020-03-14T19:45:02.620Z', - created_at: '2020-03-14T19:45:02.620Z', - data: { - policy: { - id: 'policy1', - outputs: { - default: { - type: outputType.Elasticsearch, - hosts: [], - ca_sha256: undefined, - api_key: undefined, - }, - }, - output_permissions: { - default: { _fallback: { cluster: [], indices: [] } }, - }, - inputs: [], - }, - }, - }; - - it('should return POLICY_CHANGE and data.policy for agent version >= 7.10', async () => { - const expectedResult = [ - { - agent_id: 'agent1', - created_at: '2020-03-14T19:45:02.620Z', - data: { - policy: { - id: 'policy1', - inputs: [], - outputs: { default: { api_key: 'MOCK_API_KEY', hosts: [], type: 'elasticsearch' } }, - output_permissions: { default: { _fallback: { cluster: [], indices: [] } } }, - }, - }, - id: 'action1', - sent_at: '2020-03-14T19:45:02.620Z', - type: 'POLICY_CHANGE', - }, - ]; - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - mockAgent, - mockPolicyAction - ) - ).toEqual(expectedResult); - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.10.0-SNAPSHOT' } } } }, - mockPolicyAction - ) - ).toEqual(expectedResult); - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.10.2' } } } }, - mockPolicyAction - ) - ).toEqual(expectedResult); - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - { ...mockAgent, local_metadata: { elastic: { agent: { version: '8.0.0' } } } }, - mockPolicyAction - ) - ).toEqual(expectedResult); - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - { ...mockAgent, local_metadata: { elastic: { agent: { version: '8.0.0-SNAPSHOT' } } } }, - mockPolicyAction - ) - ).toEqual(expectedResult); - }); - - it('should return CONFIG_CHANGE and data.config for agent version <= 7.9', async () => { - const expectedResult = [ - { - agent_id: 'agent1', - created_at: '2020-03-14T19:45:02.620Z', - data: { - config: { - id: 'policy1', - inputs: [], - outputs: { default: { api_key: 'MOCK_API_KEY', hosts: [], type: 'elasticsearch' } }, - output_permissions: { default: { _fallback: { cluster: [], indices: [] } } }, - }, - }, - id: 'action1', - sent_at: '2020-03-14T19:45:02.620Z', - type: 'CONFIG_CHANGE', - }, - ]; - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.9.0' } } } }, - mockPolicyAction - ) - ).toEqual(expectedResult); - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.9.3' } } } }, - mockPolicyAction - ) - ).toEqual(expectedResult); - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.9.1-SNAPSHOT' } } } }, - mockPolicyAction - ) - ).toEqual(expectedResult); - - expect( - await createAgentActionFromPolicyAction( - mockSavedObjectsClient, - mockedEsClient, - { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.8.2' } } } }, - mockPolicyAction - ) - ).toEqual(expectedResult); - }); - }); -}); diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts deleted file mode 100644 index 8f0000413471f..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts +++ /dev/null @@ -1,295 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import semverParse from 'semver/functions/parse'; -import semverLt from 'semver/functions/lt'; -import type { Observable } from 'rxjs'; -import { timer, from, TimeoutError, of, EMPTY } from 'rxjs'; -import { omit } from 'lodash'; -import { - shareReplay, - share, - distinctUntilKeyChanged, - switchMap, - exhaustMap, - concatMap, - merge, - filter, - timeout, - take, -} from 'rxjs/operators'; -import type { KibanaRequest } from 'src/core/server'; -import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; - -import type { Agent, AgentAction, AgentPolicyAction, AgentPolicyActionV7_9 } from '../../../types'; -import * as APIKeysService from '../../api_keys'; -import { - AGENT_UPDATE_ACTIONS_INTERVAL_MS, - AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS, - AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS, - AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL, -} from '../../../constants'; -import { - getNewActionsSince, - getLatestConfigChangeAction, - getAgentPolicyActionByIds, -} from '../actions'; -import { appContextService } from '../../app_context'; -import { updateAgent } from '../crud'; -import type { FullAgentPolicy, FullAgentPolicyOutputPermissions } from '../../../../common'; - -import { toPromiseAbortable, AbortError, createRateLimiter } from './rxjs_utils'; - -function getInternalUserSOClient() { - const fakeRequest = ({ - headers: {}, - getBasePath: () => '', - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - } as unknown) as KibanaRequest; - - return appContextService.getInternalUserSOClient(fakeRequest); -} - -export function createNewActionsSharedObservable(): Observable { - let lastTimestamp = new Date().toISOString(); - - return timer(0, AGENT_UPDATE_ACTIONS_INTERVAL_MS).pipe( - exhaustMap(() => { - const internalSOClient = getInternalUserSOClient(); - - return from( - getNewActionsSince(internalSOClient, lastTimestamp).then((data) => { - if (data.length > 0) { - lastTimestamp = data.reduce((acc, action) => { - return acc >= action.created_at ? acc : action.created_at; - }, lastTimestamp); - } - - return data; - }) - ); - }), - filter((data) => { - return data.length > 0; - }), - share() - ); -} - -function createAgentPolicyActionSharedObservable(agentPolicyId: string) { - const internalSOClient = getInternalUserSOClient(); - - return timer(0, AGENT_UPDATE_ACTIONS_INTERVAL_MS).pipe( - switchMap(() => from(getLatestConfigChangeAction(internalSOClient, agentPolicyId))), - filter((data): data is AgentPolicyAction => data !== undefined), - distinctUntilKeyChanged('id'), - switchMap((data) => - from(getAgentPolicyActionByIds(internalSOClient, [data.id]).then((r) => r[0])) - ), - shareReplay({ refCount: true, bufferSize: 1 }) - ); -} - -async function getAgentDefaultOutputAPIKey( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agent: Agent -) { - return agent.default_api_key; -} - -async function getOrCreateAgentDefaultOutputAPIKey( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agent: Agent, - permissions: FullAgentPolicyOutputPermissions -): Promise { - const defaultAPIKey = await getAgentDefaultOutputAPIKey(soClient, esClient, agent); - if (defaultAPIKey) { - return defaultAPIKey; - } - - const outputAPIKey = await APIKeysService.generateOutputApiKey( - soClient, - 'default', - agent.id, - permissions - ); - await updateAgent(esClient, agent.id, { - default_api_key: outputAPIKey.key, - default_api_key_id: outputAPIKey.id, - }); - return outputAPIKey.key; -} - -export async function createAgentActionFromPolicyAction( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agent: Agent, - policyAction: AgentPolicyAction -) { - // Transform the policy action for agent version <= 7.9.x for BWC - const agentVersion = semverParse((agent.local_metadata?.elastic as any)?.agent?.version); - const agentPolicyAction: AgentPolicyAction | AgentPolicyActionV7_9 = - agentVersion && - semverLt( - agentVersion, - // A prerelease tag is added here so that agent versions with prerelease tags can be compared - // correctly using `semvar` - '7.10.0-SNAPSHOT', - { includePrerelease: true } - ) - ? { - ...policyAction, - type: 'CONFIG_CHANGE', - data: { - config: policyAction.data.policy, - }, - } - : policyAction; - - // Create agent action - const newAgentAction: AgentAction = Object.assign( - omit( - // Faster than clone - JSON.parse(JSON.stringify(agentPolicyAction)) as AgentPolicyAction, - 'policy_id', - 'policy_revision' - ), - { - agent_id: agent.id, - } - ); - - // agent <= 7.9 uses `data.config` instead of `data.policy` - const policyProp = 'policy' in newAgentAction.data ? 'policy' : 'config'; - - // TODO: The null assertion `!` is strictly correct for the current use case - // where the only output is `elasticsearch`, but this might change in the future. - const permissions = (newAgentAction.data[policyProp] as FullAgentPolicy).output_permissions! - .default; - - // Mutate the policy to set the api token for this agent - const apiKey = await getOrCreateAgentDefaultOutputAPIKey(soClient, esClient, agent, permissions); - - newAgentAction.data[policyProp].outputs.default.api_key = apiKey; - - return [newAgentAction]; -} - -function getPollingTimeoutMs() { - const pollingTimeoutMs = appContextService.getConfig()?.agents.pollingRequestTimeout ?? 0; - - // If polling timeout is too short do not use margin - if (pollingTimeoutMs <= AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS) { - return pollingTimeoutMs; - } - // Set a timeout 20s before the real timeout to have a chance to respond an empty response before socket timeout - return Math.max( - pollingTimeoutMs - AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS, - AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS - ); -} - -export function agentCheckinStateNewActionsFactory() { - // Shared Observables - const agentPolicies$ = new Map>(); - const newActions$ = createNewActionsSharedObservable(); - // Rx operators - const pollingTimeoutMs = getPollingTimeoutMs(); - - const rateLimiterIntervalMs = - appContextService.getConfig()?.agents.agentPolicyRolloutRateLimitIntervalMs ?? - AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS; - const rateLimiterRequestPerInterval = - appContextService.getConfig()?.agents.agentPolicyRolloutRateLimitRequestPerInterval ?? - AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL; - const rateLimiterMaxDelay = pollingTimeoutMs; - - const rateLimiter = createRateLimiter( - rateLimiterIntervalMs, - rateLimiterRequestPerInterval, - rateLimiterMaxDelay - ); - - function getOrCreateAgentPolicyObservable(agentPolicyId: string) { - if (!agentPolicies$.has(agentPolicyId)) { - agentPolicies$.set(agentPolicyId, createAgentPolicyActionSharedObservable(agentPolicyId)); - } - const agentPolicy$ = agentPolicies$.get(agentPolicyId); - if (!agentPolicy$) { - throw new Error(`Invalid state, no observable for policy ${agentPolicyId}`); - } - - return agentPolicy$; - } - - async function subscribeToNewActions( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agent: Agent, - options?: { signal: AbortSignal } - ): Promise { - if (!agent.policy_id) { - throw new Error('Agent does not have a policy'); - } - const agentPolicy$ = getOrCreateAgentPolicyObservable(agent.policy_id); - - const stream$ = agentPolicy$.pipe( - timeout(pollingTimeoutMs), - filter( - (action) => - agent.policy_id !== undefined && - action.policy_revision !== undefined && - action.policy_id !== undefined && - action.policy_id === agent.policy_id && - (!agent.policy_revision || action.policy_revision > agent.policy_revision) - ), - rateLimiter(), - concatMap((policyAction) => - createAgentActionFromPolicyAction(soClient, esClient, agent, policyAction) - ), - merge(newActions$), - concatMap((data: AgentAction[] | undefined) => { - if (data === undefined) { - return EMPTY; - } - const newActions = data.filter((action) => action.agent_id === agent.id); - if (newActions.length === 0) { - return EMPTY; - } - - return of(newActions); - }), - filter((data) => data !== undefined), - take(1) - ); - try { - const data = await toPromiseAbortable(stream$, options?.signal); - return data || []; - } catch (err) { - if (err instanceof TimeoutError || err instanceof AbortError) { - return []; - } - - throw err; - } - } - - return { - subscribeToNewActions, - }; -} diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index a23efa1e50fc0..b8ce7c36e507f 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -255,9 +255,10 @@ export async function getAgentByAccessAPIKeyId( q: `access_api_key_id:${escapeSearchQueryPhrase(accessAPIKeyId)}`, }); - const agent = searchHitToAgent(res.body.hits.hits[0]); + const searchHit = res.body.hits.hits[0]; + const agent = searchHit && searchHitToAgent(searchHit); - if (!agent) { + if (!searchHit || !agent) { throw new AgentNotFoundError('Agent not found'); } if (agent.access_api_key_id !== accessAPIKeyId) { diff --git a/x-pack/plugins/fleet/server/services/agents/enroll.test.ts b/x-pack/plugins/fleet/server/services/agents/enroll.test.ts deleted file mode 100644 index 676e5a155aef2..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/enroll.test.ts +++ /dev/null @@ -1,57 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { validateAgentVersion } from './enroll'; - -describe('validateAgentVersion', () => { - it('should throw with agent > kibana version', () => { - expect(() => validateAgentVersion('8.8.0', '8.0.0')).toThrowError('not compatible'); - }); - it('should work with agent < kibana version', () => { - validateAgentVersion('7.8.0', '8.0.0'); - }); - - it('should work with agent = kibana version', () => { - validateAgentVersion('8.0.0', '8.0.0'); - }); - - it('should work with SNAPSHOT version', () => { - validateAgentVersion('8.0.0-SNAPSHOT', '8.0.0-SNAPSHOT'); - }); - - it('should work with a agent using SNAPSHOT version', () => { - validateAgentVersion('7.8.0-SNAPSHOT', '7.8.0'); - }); - - it('should work with a kibana using SNAPSHOT version', () => { - validateAgentVersion('7.8.0', '7.8.0-SNAPSHOT'); - }); - - it('very close versions, e.g. patch/prerelease - all combos should work', () => { - validateAgentVersion('7.9.1', '7.9.2'); - validateAgentVersion('7.8.1', '7.8.2'); - validateAgentVersion('7.6.99', '7.6.2'); - validateAgentVersion('7.6.2', '7.6.99'); - validateAgentVersion('5.94.3', '5.94.1234-SNAPSHOT'); - validateAgentVersion('5.94.3-SNAPSHOT', '5.94.1'); - }); - - it('somewhat close versions, minor release is 1 or 2 versions back and is older than the stack', () => { - validateAgentVersion('7.9.1', '7.10.2'); - validateAgentVersion('7.9.9', '7.11.1'); - validateAgentVersion('7.6.99', '7.6.2'); - validateAgentVersion('7.6.2', '7.6.99'); - expect(() => validateAgentVersion('5.94.3-SNAPSHOT', '5.93.1')).toThrowError('not compatible'); - expect(() => validateAgentVersion('5.94.3', '5.92.99-SNAPSHOT')).toThrowError('not compatible'); - }); - - it('versions where Agent is a minor version or major version greater (newer) than the stack should not work', () => { - expect(() => validateAgentVersion('7.10.1', '7.9.99')).toThrowError('not compatible'); - expect(() => validateAgentVersion('7.9.9', '6.11.1')).toThrowError('not compatible'); - expect(() => validateAgentVersion('5.94.3', '5.92.99-SNAPSHOT')).toThrowError('not compatible'); - }); -}); diff --git a/x-pack/plugins/fleet/server/services/agents/enroll.ts b/x-pack/plugins/fleet/server/services/agents/enroll.ts deleted file mode 100644 index c9148f6249fa5..0000000000000 --- a/x-pack/plugins/fleet/server/services/agents/enroll.ts +++ /dev/null @@ -1,111 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import Boom from '@hapi/boom'; -import uuid from 'uuid/v4'; -import semverParse from 'semver/functions/parse'; -import semverDiff from 'semver/functions/diff'; -import semverLte from 'semver/functions/lte'; -import type { SavedObjectsClientContract } from 'src/core/server'; - -import type { AgentType, Agent, FleetServerAgent } from '../../types'; -import { AGENTS_INDEX } from '../../constants'; -import { IngestManagerError } from '../../errors'; -import * as APIKeyService from '../api_keys'; -import { agentPolicyService } from '../../services'; -import { appContextService } from '../app_context'; - -export async function enroll( - soClient: SavedObjectsClientContract, - type: AgentType, - agentPolicyId: string, - metadata?: { local: any; userProvided: any } -): Promise { - const agentVersion = metadata?.local?.elastic?.agent?.version; - validateAgentVersion(agentVersion); - - const agentPolicy = await agentPolicyService.get(soClient, agentPolicyId, false); - if (agentPolicy?.is_managed) { - throw new IngestManagerError(`Cannot enroll in managed policy ${agentPolicyId}`); - } - - const esClient = appContextService.getInternalUserESClient(); - - const agentId = uuid(); - const accessAPIKey = await APIKeyService.generateAccessApiKey(soClient, agentId); - const fleetServerAgent: FleetServerAgent = { - active: true, - policy_id: agentPolicyId, - type, - enrolled_at: new Date().toISOString(), - user_provided_metadata: metadata?.userProvided ?? {}, - local_metadata: metadata?.local ?? {}, - access_api_key_id: accessAPIKey.id, - }; - await esClient.create({ - index: AGENTS_INDEX, - body: fleetServerAgent, - id: agentId, - refresh: 'wait_for', - }); - - return { - id: agentId, - current_error_events: [], - packages: [], - ...fleetServerAgent, - access_api_key: accessAPIKey.key, - } as Agent; -} - -export function validateAgentVersion( - agentVersion: string, - kibanaVersion = appContextService.getKibanaVersion() -) { - const agentVersionParsed = semverParse(agentVersion); - if (!agentVersionParsed) { - throw Boom.badRequest('Agent version not provided'); - } - - const kibanaVersionParsed = semverParse(kibanaVersion); - if (!kibanaVersionParsed) { - throw Boom.badRequest('Kibana version is not set or provided'); - } - - const diff = semverDiff(agentVersion, kibanaVersion); - switch (diff) { - // section 1) very close versions, only patch release differences - all combos should work - // Agent a.b.1 < Kibana a.b.2 - // Agent a.b.2 > Kibana a.b.1 - case null: - case 'prerelease': - case 'prepatch': - case 'patch': - return; // OK - - // section 2) somewhat close versions, Agent minor release is 1 or 2 versions back and is older than the stack: - // Agent a.9.x < Kibana a.10.x - // Agent a.9.x < Kibana a.11.x - case 'preminor': - case 'minor': - if ( - agentVersionParsed.minor < kibanaVersionParsed.minor && - kibanaVersionParsed.minor - agentVersionParsed.minor <= 2 - ) - return; - - // section 3) versions where Agent is a minor version or major version greater (newer) than the stack should not work: - // Agent 7.10.x > Kibana 7.9.x - // Agent 8.0.x > Kibana 7.9.x - default: - if (semverLte(agentVersionParsed, kibanaVersionParsed)) return; - else - throw Boom.badRequest( - `Agent version ${agentVersion} is not compatible with Kibana version ${kibanaVersion}` - ); - } -} diff --git a/x-pack/plugins/fleet/server/services/agents/index.ts b/x-pack/plugins/fleet/server/services/agents/index.ts index 0b28b5050572a..66303514c4fe7 100644 --- a/x-pack/plugins/fleet/server/services/agents/index.ts +++ b/x-pack/plugins/fleet/server/services/agents/index.ts @@ -5,10 +5,7 @@ * 2.0. */ -export * from './acks'; export * from './events'; -export * from './checkin'; -export * from './enroll'; export * from './unenroll'; export * from './upgrade'; export * from './status'; diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts index f040ba57c38be..4dfc29df8c398 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts @@ -13,63 +13,63 @@ import { AgentReassignmentError } from '../../errors'; import { reassignAgent, reassignAgents } from './reassign'; -const agentInManagedDoc = { - _id: 'agent-in-managed-policy', - _source: { policy_id: 'managed-agent-policy' }, +const agentInHostedDoc = { + _id: 'agent-in-hosted-policy', + _source: { policy_id: 'hosted-agent-policy' }, }; -const agentInManagedDoc2 = { - _id: 'agent-in-managed-policy2', - _source: { policy_id: 'managed-agent-policy' }, +const agentInHostedDoc2 = { + _id: 'agent-in-hosted-policy2', + _source: { policy_id: 'hosted-agent-policy' }, }; -const agentInUnmanagedDoc = { - _id: 'agent-in-unmanaged-policy', - _source: { policy_id: 'unmanaged-agent-policy' }, +const agentInRegularDoc = { + _id: 'agent-in-regular-policy', + _source: { policy_id: 'regular-agent-policy' }, }; -const unmanagedAgentPolicySO = { - id: 'unmanaged-agent-policy', +const regularAgentPolicySO = { + id: 'regular-agent-policy', attributes: { is_managed: false }, } as SavedObject; -const unmanagedAgentPolicySO2 = { - id: 'unmanaged-agent-policy-2', +const regularAgentPolicySO2 = { + id: 'regular-agent-policy-2', attributes: { is_managed: false }, } as SavedObject; -const managedAgentPolicySO = { - id: 'managed-agent-policy', +const hostedAgentPolicySO = { + id: 'hosted-agent-policy', attributes: { is_managed: true }, } as SavedObject; describe('reassignAgent (singular)', () => { - it('can reassign from unmanaged policy to unmanaged', async () => { + it('can reassign from regular agent policy to regular', async () => { const { soClient, esClient } = createClientsMock(); - await reassignAgent(soClient, esClient, agentInUnmanagedDoc._id, unmanagedAgentPolicySO.id); + await reassignAgent(soClient, esClient, agentInRegularDoc._id, regularAgentPolicySO.id); // calls ES update with correct values expect(esClient.update).toBeCalledTimes(1); const calledWith = esClient.update.mock.calls[0]; - expect(calledWith[0]?.id).toBe(agentInUnmanagedDoc._id); - expect(calledWith[0]?.body?.doc).toHaveProperty('policy_id', unmanagedAgentPolicySO.id); + expect(calledWith[0]?.id).toBe(agentInRegularDoc._id); + expect(calledWith[0]?.body?.doc).toHaveProperty('policy_id', regularAgentPolicySO.id); }); - it('cannot reassign from unmanaged policy to managed', async () => { + it('cannot reassign from regular agent policy to hosted', async () => { const { soClient, esClient } = createClientsMock(); await expect( - reassignAgent(soClient, esClient, agentInUnmanagedDoc._id, managedAgentPolicySO.id) + reassignAgent(soClient, esClient, agentInRegularDoc._id, hostedAgentPolicySO.id) ).rejects.toThrowError(AgentReassignmentError); // does not call ES update expect(esClient.update).toBeCalledTimes(0); }); - it('cannot reassign from managed policy', async () => { + it('cannot reassign from hosted agent policy', async () => { const { soClient, esClient } = createClientsMock(); await expect( - reassignAgent(soClient, esClient, agentInManagedDoc._id, unmanagedAgentPolicySO.id) + reassignAgent(soClient, esClient, agentInHostedDoc._id, regularAgentPolicySO.id) ).rejects.toThrowError(AgentReassignmentError); // does not call ES update expect(esClient.update).toBeCalledTimes(0); await expect( - reassignAgent(soClient, esClient, agentInManagedDoc._id, managedAgentPolicySO.id) + reassignAgent(soClient, esClient, agentInHostedDoc._id, hostedAgentPolicySO.id) ).rejects.toThrowError(AgentReassignmentError); // does not call ES update expect(esClient.update).toBeCalledTimes(0); @@ -77,22 +77,17 @@ describe('reassignAgent (singular)', () => { }); describe('reassignAgents (plural)', () => { - it('agents in managed policies are not updated', async () => { + it('agents in hosted policies are not updated', async () => { const { soClient, esClient } = createClientsMock(); - const idsToReassign = [agentInUnmanagedDoc._id, agentInManagedDoc._id, agentInManagedDoc2._id]; - await reassignAgents( - soClient, - esClient, - { agentIds: idsToReassign }, - unmanagedAgentPolicySO2.id - ); + const idsToReassign = [agentInRegularDoc._id, agentInHostedDoc._id, agentInHostedDoc2._id]; + await reassignAgents(soClient, esClient, { agentIds: idsToReassign }, regularAgentPolicySO2.id); // calls ES update with correct values const calledWith = esClient.bulk.mock.calls[0][0]; - // only 1 are unmanaged and bulk write two line per update + // only 1 are regular and bulk write two line per update expect(calledWith.body.length).toBe(2); // @ts-expect-error - expect(calledWith.body[0].update._id).toEqual(agentInUnmanagedDoc._id); + expect(calledWith.body[0].update._id).toEqual(agentInRegularDoc._id); }); }); @@ -112,12 +107,12 @@ function createClientsMock() { }); soClientMock.get.mockImplementation(async (_, id) => { switch (id) { - case unmanagedAgentPolicySO.id: - return unmanagedAgentPolicySO; - case managedAgentPolicySO.id: - return managedAgentPolicySO; - case unmanagedAgentPolicySO2.id: - return unmanagedAgentPolicySO2; + case regularAgentPolicySO.id: + return regularAgentPolicySO; + case hostedAgentPolicySO.id: + return hostedAgentPolicySO; + case regularAgentPolicySO2.id: + return regularAgentPolicySO2; default: throw new Error(`${id} not found`); } @@ -133,17 +128,17 @@ function createClientsMock() { esClientMock.mget.mockImplementation(async () => { return { body: { - docs: [agentInManagedDoc, agentInUnmanagedDoc, agentInManagedDoc2], + docs: [agentInHostedDoc, agentInRegularDoc, agentInHostedDoc2], }, }; }); // @ts-expect-error esClientMock.get.mockImplementation(async ({ id }) => { switch (id) { - case agentInManagedDoc._id: - return { body: agentInManagedDoc }; - case agentInUnmanagedDoc._id: - return { body: agentInUnmanagedDoc }; + case agentInHostedDoc._id: + return { body: agentInHostedDoc }; + case agentInRegularDoc._id: + return { body: agentInRegularDoc }; default: throw new Error(`${id} not found`); } diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index 81b00663d7a8a..4c95d19e2f13a 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -41,7 +41,7 @@ export async function reassignAgent( policy_revision: null, }); - await createAgentAction(soClient, esClient, { + await createAgentAction(esClient, { agent_id: agentId, created_at: new Date().toISOString(), type: 'POLICY_REASSIGN', @@ -57,14 +57,14 @@ export async function reassignAgentIsAllowed( const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); if (agentPolicy?.is_managed) { throw new AgentReassignmentError( - `Cannot reassign an agent from managed agent policy ${agentPolicy.id}` + `Cannot reassign an agent from hosted agent policy ${agentPolicy.id}` ); } const newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); if (newAgentPolicy?.is_managed) { throw new AgentReassignmentError( - `Cannot reassign an agent to managed agent policy ${newAgentPolicy.id}` + `Cannot reassign an agent to hosted agent policy ${newAgentPolicy.id}` ); } @@ -159,7 +159,6 @@ export async function reassignAgents( const now = new Date().toISOString(); await bulkCreateAgentActions( - soClient, esClient, agentsToUpdate.map((agent) => ({ agent_id: agent.id, diff --git a/x-pack/plugins/fleet/server/services/agents/setup.ts b/x-pack/plugins/fleet/server/services/agents/setup.ts index 67c1715ca8be4..81ae6b177783d 100644 --- a/x-pack/plugins/fleet/server/services/agents/setup.ts +++ b/x-pack/plugins/fleet/server/services/agents/setup.ts @@ -9,14 +9,6 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/s import { SO_SEARCH_LIMIT } from '../../constants'; import { agentPolicyService } from '../agent_policy'; -import { outputService } from '../output'; - -export async function isAgentsSetup(soClient: SavedObjectsClientContract): Promise { - const adminUser = await outputService.getAdminUser(soClient, false); - const outputId = await outputService.getDefaultOutputId(soClient); - // If admin user (fleet_enroll) and output id exist Agents are correctly setup - return adminUser && outputId ? true : false; -} /** * During the migration from 7.9 to 7.10 we introduce a new agent action POLICY_CHANGE per policy diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index 938ece1364b40..24a3dea3bcb91 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -13,82 +13,82 @@ import { AgentUnenrollmentError } from '../../errors'; import { unenrollAgent, unenrollAgents } from './unenroll'; -const agentInManagedDoc = { - _id: 'agent-in-managed-policy', - _source: { policy_id: 'managed-agent-policy' }, +const agentInHostedDoc = { + _id: 'agent-in-hosted-policy', + _source: { policy_id: 'hosted-agent-policy' }, }; -const agentInUnmanagedDoc = { - _id: 'agent-in-unmanaged-policy', - _source: { policy_id: 'unmanaged-agent-policy' }, +const agentInRegularDoc = { + _id: 'agent-in-regular-policy', + _source: { policy_id: 'regular-agent-policy' }, }; -const agentInUnmanagedDoc2 = { - _id: 'agent-in-unmanaged-policy2', - _source: { policy_id: 'unmanaged-agent-policy' }, +const agentInRegularDoc2 = { + _id: 'agent-in-regular-policy2', + _source: { policy_id: 'regular-agent-policy' }, }; -const unmanagedAgentPolicySO = { - id: 'unmanaged-agent-policy', +const regularAgentPolicySO = { + id: 'regular-agent-policy', attributes: { is_managed: false }, } as SavedObject; -const managedAgentPolicySO = { - id: 'managed-agent-policy', +const hostedAgentPolicySO = { + id: 'hosted-agent-policy', attributes: { is_managed: true }, } as SavedObject; describe('unenrollAgent (singular)', () => { - it('can unenroll from unmanaged policy', async () => { + it('can unenroll from regular agent policy', async () => { const { soClient, esClient } = createClientMock(); - await unenrollAgent(soClient, esClient, agentInUnmanagedDoc._id); + await unenrollAgent(soClient, esClient, agentInRegularDoc._id); // calls ES update with correct values expect(esClient.update).toBeCalledTimes(1); const calledWith = esClient.update.mock.calls[0]; - expect(calledWith[0]?.id).toBe(agentInUnmanagedDoc._id); + expect(calledWith[0]?.id).toBe(agentInRegularDoc._id); expect(calledWith[0]?.body).toHaveProperty('doc.unenrollment_started_at'); }); - it('cannot unenroll from managed policy by default', async () => { + it('cannot unenroll from hosted agent policy by default', async () => { const { soClient, esClient } = createClientMock(); - await expect(unenrollAgent(soClient, esClient, agentInManagedDoc._id)).rejects.toThrowError( + await expect(unenrollAgent(soClient, esClient, agentInHostedDoc._id)).rejects.toThrowError( AgentUnenrollmentError ); // does not call ES update expect(esClient.update).toBeCalledTimes(0); }); - it('cannot unenroll from managed policy with revoke=true', async () => { + it('cannot unenroll from hosted agent policy with revoke=true', async () => { const { soClient, esClient } = createClientMock(); await expect( - unenrollAgent(soClient, esClient, agentInManagedDoc._id, { revoke: true }) + unenrollAgent(soClient, esClient, agentInHostedDoc._id, { revoke: true }) ).rejects.toThrowError(AgentUnenrollmentError); // does not call ES update expect(esClient.update).toBeCalledTimes(0); }); - it('can unenroll from managed policy with force=true', async () => { + it('can unenroll from hosted agent policy with force=true', async () => { const { soClient, esClient } = createClientMock(); - await unenrollAgent(soClient, esClient, agentInManagedDoc._id, { force: true }); + await unenrollAgent(soClient, esClient, agentInHostedDoc._id, { force: true }); // calls ES update with correct values expect(esClient.update).toBeCalledTimes(1); const calledWith = esClient.update.mock.calls[0]; - expect(calledWith[0]?.id).toBe(agentInManagedDoc._id); + expect(calledWith[0]?.id).toBe(agentInHostedDoc._id); expect(calledWith[0]?.body).toHaveProperty('doc.unenrollment_started_at'); }); - it('can unenroll from managed policy with force=true and revoke=true', async () => { + it('can unenroll from hosted agent policy with force=true and revoke=true', async () => { const { soClient, esClient } = createClientMock(); - await unenrollAgent(soClient, esClient, agentInManagedDoc._id, { force: true, revoke: true }); + await unenrollAgent(soClient, esClient, agentInHostedDoc._id, { force: true, revoke: true }); // calls ES update with correct values expect(esClient.update).toBeCalledTimes(1); const calledWith = esClient.update.mock.calls[0]; - expect(calledWith[0]?.id).toBe(agentInManagedDoc._id); + expect(calledWith[0]?.id).toBe(agentInHostedDoc._id); expect(calledWith[0]?.body).toHaveProperty('doc.unenrolled_at'); }); }); describe('unenrollAgents (plural)', () => { - it('can unenroll from an unmanaged policy', async () => { + it('can unenroll from an regular agent policy', async () => { const { soClient, esClient } = createClientMock(); - const idsToUnenroll = [agentInUnmanagedDoc._id, agentInUnmanagedDoc2._id]; + const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); // calls ES update with correct values @@ -102,37 +102,29 @@ describe('unenrollAgents (plural)', () => { expect(doc).toHaveProperty('unenrollment_started_at'); } }); - it('cannot unenroll from a managed policy by default', async () => { + it('cannot unenroll from a hosted agent policy by default', async () => { const { soClient, esClient } = createClientMock(); - const idsToUnenroll = [ - agentInUnmanagedDoc._id, - agentInManagedDoc._id, - agentInUnmanagedDoc2._id, - ]; + const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); // calls ES update with correct values - const onlyUnmanaged = [agentInUnmanagedDoc._id, agentInUnmanagedDoc2._id]; + const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; const calledWith = esClient.bulk.mock.calls[1][0]; const ids = calledWith?.body .filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); - expect(ids).toEqual(onlyUnmanaged); + expect(ids).toEqual(onlyRegular); for (const doc of docs) { expect(doc).toHaveProperty('unenrollment_started_at'); } }); - it('cannot unenroll from a managed policy with revoke=true', async () => { + it('cannot unenroll from a hosted agent policy with revoke=true', async () => { const { soClient, esClient } = createClientMock(); - const idsToUnenroll = [ - agentInUnmanagedDoc._id, - agentInManagedDoc._id, - agentInUnmanagedDoc2._id, - ]; + const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; const unenrolledResponse = await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, @@ -141,39 +133,35 @@ describe('unenrollAgents (plural)', () => { expect(unenrolledResponse.items).toMatchObject([ { - id: 'agent-in-unmanaged-policy', + id: 'agent-in-regular-policy', success: true, }, { - id: 'agent-in-managed-policy', + id: 'agent-in-hosted-policy', success: false, }, { - id: 'agent-in-unmanaged-policy2', + id: 'agent-in-regular-policy2', success: true, }, ]); // calls ES update with correct values - const onlyUnmanaged = [agentInUnmanagedDoc._id, agentInUnmanagedDoc2._id]; + const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; const calledWith = esClient.bulk.mock.calls[0][0]; const ids = calledWith?.body .filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); - expect(ids).toEqual(onlyUnmanaged); + expect(ids).toEqual(onlyRegular); for (const doc of docs) { expect(doc).toHaveProperty('unenrolled_at'); } }); - it('can unenroll from managed policy with force=true', async () => { + it('can unenroll from hosted agent policy with force=true', async () => { const { soClient, esClient } = createClientMock(); - const idsToUnenroll = [ - agentInUnmanagedDoc._id, - agentInManagedDoc._id, - agentInUnmanagedDoc2._id, - ]; + const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true }); // calls ES update with correct values @@ -188,14 +176,10 @@ describe('unenrollAgents (plural)', () => { } }); - it('can unenroll from managed policy with force=true and revoke=true', async () => { + it('can unenroll from hosted agent policy with force=true and revoke=true', async () => { const { soClient, esClient } = createClientMock(); - const idsToUnenroll = [ - agentInUnmanagedDoc._id, - agentInManagedDoc._id, - agentInUnmanagedDoc2._id, - ]; + const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; const unenrolledResponse = await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, @@ -205,15 +189,15 @@ describe('unenrollAgents (plural)', () => { expect(unenrolledResponse.items).toMatchObject([ { - id: 'agent-in-unmanaged-policy', + id: 'agent-in-regular-policy', success: true, }, { - id: 'agent-in-managed-policy', + id: 'agent-in-hosted-policy', success: true, }, { - id: 'agent-in-unmanaged-policy2', + id: 'agent-in-regular-policy2', success: true, }, ]); @@ -248,10 +232,10 @@ function createClientMock() { soClientMock.get.mockImplementation(async (_, id) => { switch (id) { - case unmanagedAgentPolicySO.id: - return unmanagedAgentPolicySO; - case managedAgentPolicySO.id: - return managedAgentPolicySO; + case regularAgentPolicySO.id: + return regularAgentPolicySO; + case hostedAgentPolicySO.id: + return hostedAgentPolicySO; default: throw new Error('not found'); } @@ -267,12 +251,12 @@ function createClientMock() { // @ts-expect-error esClientMock.get.mockImplementation(async ({ id }) => { switch (id) { - case agentInManagedDoc._id: - return { body: agentInManagedDoc }; - case agentInUnmanagedDoc2._id: - return { body: agentInUnmanagedDoc2 }; - case agentInUnmanagedDoc._id: - return { body: agentInUnmanagedDoc }; + case agentInHostedDoc._id: + return { body: agentInHostedDoc }; + case agentInRegularDoc2._id: + return { body: agentInRegularDoc2 }; + case agentInRegularDoc._id: + return { body: agentInRegularDoc }; default: throw new Error('not found'); } @@ -287,12 +271,12 @@ function createClientMock() { // @ts-expect-error const docs = params?.body.docs.map(({ _id }) => { switch (_id) { - case agentInManagedDoc._id: - return agentInManagedDoc; - case agentInUnmanagedDoc2._id: - return agentInUnmanagedDoc2; - case agentInUnmanagedDoc._id: - return agentInUnmanagedDoc; + case agentInHostedDoc._id: + return agentInHostedDoc; + case agentInRegularDoc2._id: + return agentInRegularDoc2; + case agentInRegularDoc._id: + return agentInRegularDoc; default: throw new Error('not found'); } diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 85bc5eecd78b9..fc1f80fe7521b 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -29,7 +29,7 @@ async function unenrollAgentIsAllowed( const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); if (agentPolicy?.is_managed) { throw new AgentUnenrollmentError( - `Cannot unenroll ${agentId} from a managed agent policy ${agentPolicy.id}` + `Cannot unenroll ${agentId} from a hosted agent policy ${agentPolicy.id}` ); } @@ -52,7 +52,7 @@ export async function unenrollAgent( return forceUnenrollAgent(soClient, esClient, agentId); } const now = new Date().toISOString(); - await createAgentAction(soClient, esClient, { + await createAgentAction(esClient, { agent_id: agentId, created_at: now, type: 'UNENROLL', @@ -106,7 +106,6 @@ export async function unenrollAgents( } else { // Create unenroll action for each agent await bulkCreateAgentActions( - soClient, esClient, agentsToUpdate.map((agent) => ({ agent_id: agent.id, diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index d8dd1167d3653..61e785828bf23 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -47,11 +47,11 @@ export async function sendUpgradeAgentAction({ const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); if (agentPolicy?.is_managed) { throw new IngestManagerError( - `Cannot upgrade agent ${agentId} in managed policy ${agentPolicy.id}` + `Cannot upgrade agent ${agentId} in hosted agent policy ${agentPolicy.id}` ); } - await createAgentAction(soClient, esClient, { + await createAgentAction(esClient, { agent_id: agentId, created_at: now, data, @@ -119,17 +119,17 @@ export async function sendUpgradeAgentsActions( const agentPolicies = await agentPolicyService.getByIDs(soClient, Array.from(policyIdsToGet), { fields: ['is_managed'], }); - const managedPolicies = agentPolicies.reduce>((acc, policy) => { + const hostedPolicies = agentPolicies.reduce>((acc, policy) => { acc[policy.id] = policy.is_managed; return acc; }, {}); - const isManagedAgent = (agent: Agent) => agent.policy_id && managedPolicies[agent.policy_id]; + const isHostedAgent = (agent: Agent) => agent.policy_id && hostedPolicies[agent.policy_id]; - // results from getAgents with options.kuery '' (or even 'active:false') may include managed agents + // results from getAgents with options.kuery '' (or even 'active:false') may include hosted agents // filter them out unless options.force const agentsToCheckUpgradeable = 'kuery' in options && !options.force - ? givenAgents.filter((agent: Agent) => !isManagedAgent(agent)) + ? givenAgents.filter((agent: Agent) => !isHostedAgent(agent)) : givenAgents; const kibanaVersion = appContextService.getKibanaVersion(); @@ -141,8 +141,10 @@ export async function sendUpgradeAgentsActions( throw new IngestManagerError(`${agent.id} is not upgradeable`); } - if (!options.force && isManagedAgent(agent)) { - throw new IngestManagerError(`Cannot upgrade agent in managed policy ${agent.policy_id}`); + if (!options.force && isHostedAgent(agent)) { + throw new IngestManagerError( + `Cannot upgrade agent in hosted agent policy ${agent.policy_id}` + ); } return agent; }) @@ -167,7 +169,6 @@ export async function sendUpgradeAgentsActions( }; await bulkCreateAgentActions( - soClient, esClient, agentsToUpdate.map((agent) => ({ agent_id: agent.id, diff --git a/x-pack/plugins/fleet/server/services/api_keys/index.ts b/x-pack/plugins/fleet/server/services/api_keys/index.ts index 1f9e77821360c..c781b2d01943f 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/index.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/index.ts @@ -6,53 +6,10 @@ */ import type { KibanaRequest } from 'src/core/server'; -import type { SavedObjectsClientContract } from 'src/core/server'; - -import type { FullAgentPolicyOutputPermissions } from '../../../common'; - -import { createAPIKey } from './security'; export { invalidateAPIKeys } from './security'; export * from './enrollment_api_key'; -export async function generateOutputApiKey( - soClient: SavedObjectsClientContract, - outputId: string, - agentId: string, - permissions: FullAgentPolicyOutputPermissions -): Promise<{ key: string; id: string }> { - const name = `${agentId}:${outputId}`; - const key = await createAPIKey(soClient, name, permissions); - - if (!key) { - throw new Error('Unable to create an output api key'); - } - - return { key: `${key.id}:${key.api_key}`, id: key.id }; -} - -export async function generateAccessApiKey(soClient: SavedObjectsClientContract, agentId: string) { - const key = await createAPIKey(soClient, agentId, { - // Useless role to avoid to have the privilege of the user that created the key - 'fleet-apikey-access': { - cluster: [], - applications: [ - { - application: '.fleet', - privileges: ['no-privileges'], - resources: ['*'], - }, - ], - }, - }); - - if (!key) { - throw new Error('Unable to create an access api key'); - } - - return { id: key.id, key: Buffer.from(`${key.id}:${key.api_key}`).toString('base64') }; -} - export function parseApiKeyFromHeaders(headers: KibanaRequest['headers']) { const authorizationHeader = headers.authorization; diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index c49e536435027..954308a980861 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -44,11 +44,6 @@ class AppContextService { private httpSetup?: HttpServiceSetup; private externalCallbacks: ExternalCallbacksStorage = new Map(); - /** - * Temporary flag until v7.13 ships - */ - public fleetServerEnabled: boolean = false; - public async start(appContext: FleetAppContext) { this.data = appContext.data; this.esClient = appContext.elasticsearch.client.asInternalUser; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts b/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts index 7323263d4a70f..2c5b072aa3979 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts @@ -32,22 +32,31 @@ export async function bulkInstallPackages({ ); logger.debug(`kicking off bulk install of ${packagesToInstall.join(', ')} from registry`); - const installResults = await Promise.allSettled( + const bulkInstallResults = await Promise.allSettled( latestPackagesResults.map(async (result, index) => { const packageName = packagesToInstall[index]; if (result.status === 'fulfilled') { const latestPackage = result.value; - return { - name: packageName, - version: latestPackage.version, - result: await installPackage({ - savedObjectsClient, - esClient, - pkgkey: Registry.pkgToPkgKey(latestPackage), - installSource, - skipPostInstall: true, - }), - }; + const installResult = await installPackage({ + savedObjectsClient, + esClient, + pkgkey: Registry.pkgToPkgKey(latestPackage), + installSource, + skipPostInstall: true, + }); + if (installResult.error) { + return { + name: packageName, + error: installResult.error, + installType: installResult.installType, + }; + } else { + return { + name: packageName, + version: latestPackage.version, + result: installResult, + }; + } } return { name: packageName, error: result.reason }; }) @@ -56,18 +65,31 @@ export async function bulkInstallPackages({ // only install index patterns if we completed install for any package-version for the // first time, aka fresh installs or upgrades if ( - installResults.find( - (result) => result.status === 'fulfilled' && result.value.result?.status === 'installed' + bulkInstallResults.find( + (result) => + result.status === 'fulfilled' && + !result.value.result?.error && + result.value.result?.status === 'installed' ) ) { await installIndexPatterns({ savedObjectsClient, esClient, installSource }); } - return installResults.map((result, index) => { + return bulkInstallResults.map((result, index) => { const packageName = packagesToInstall[index]; - return result.status === 'fulfilled' - ? result.value - : { name: packageName, error: result.reason }; + if (result.status === 'fulfilled') { + if (result.value && result.value.error) { + return { + name: packageName, + error: result.value.error, + installType: result.value.installType, + }; + } else { + return result.value; + } + } else { + return { name: packageName, error: result.reason }; + } }); } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts index fa2ea9e2209ed..60e2e5ea2cbf8 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts @@ -77,14 +77,14 @@ describe('ensureInstalledDefaultPackages', () => { return [ { name: mockInstallation.attributes.name, - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, ]; }); const resp = await ensureInstalledDefaultPackages(soClient, jest.fn()); - expect(resp).toEqual([mockInstallation.attributes]); + expect(resp.installations).toEqual([mockInstallation.attributes]); }); it('should throw the first Error it finds', async () => { class SomeCustomError extends Error {} @@ -95,13 +95,13 @@ describe('ensureInstalledDefaultPackages', () => { return [ { name: 'success one', - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, { name: 'success two', - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, @@ -111,7 +111,7 @@ describe('ensureInstalledDefaultPackages', () => { }, { name: 'success three', - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, @@ -134,7 +134,7 @@ describe('ensureInstalledDefaultPackages', () => { return [ { name: 'undefined package', - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 168ec55b14876..ec1cc322475b0 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -12,7 +12,12 @@ import type { ElasticsearchClient, SavedObject, SavedObjectsClientContract } fro import { generateESIndexPatterns } from '../elasticsearch/template/template'; import { defaultPackages } from '../../../../common'; -import type { BulkInstallPackageInfo, InstallablePackage, InstallSource } from '../../../../common'; +import type { + BulkInstallPackageInfo, + InstallablePackage, + InstallSource, + DefaultPackagesInstallationError, +} from '../../../../common'; import { IngestManagerError, PackageOperationNotSupportedError, @@ -21,7 +26,6 @@ import { import { PACKAGES_SAVED_OBJECT_TYPE, MAX_TIME_COMPLETE_INSTALL } from '../../../constants'; import type { KibanaAssetType } from '../../../types'; import type { - AssetReference, Installation, AssetType, EsAssetReference, @@ -46,34 +50,17 @@ import { removeInstallation } from './remove'; import { getPackageSavedObjects } from './get'; import { _installPackage } from './_install_package'; -export async function installLatestPackage(options: { - savedObjectsClient: SavedObjectsClientContract; - pkgName: string; - esClient: ElasticsearchClient; -}): Promise { - const { savedObjectsClient, pkgName, esClient } = options; - try { - const latestPackage = await Registry.fetchFindLatestPackage(pkgName); - const pkgkey = Registry.pkgToPkgKey({ - name: latestPackage.name, - version: latestPackage.version, - }); - return installPackage({ - installSource: 'registry', - savedObjectsClient, - pkgkey, - esClient, - }).then(({ assets }) => assets); - } catch (err) { - throw err; - } +export interface DefaultPackagesInstallationResult { + installations: Installation[]; + nonFatalPackageUpgradeErrors: DefaultPackagesInstallationError[]; } export async function ensureInstalledDefaultPackages( savedObjectsClient: SavedObjectsClientContract, esClient: ElasticsearchClient -): Promise { +): Promise { const installations = []; + const nonFatalPackageUpgradeErrors = []; const bulkResponse = await bulkInstallPackages({ savedObjectsClient, packagesToInstall: Object.values(defaultPackages), @@ -82,29 +69,40 @@ export async function ensureInstalledDefaultPackages( for (const resp of bulkResponse) { if (isBulkInstallError(resp)) { - throw resp.error; + if (resp.installType && (resp.installType === 'update' || resp.installType === 'reupdate')) { + nonFatalPackageUpgradeErrors.push({ installType: resp.installType, error: resp.error }); + } else { + throw resp.error; + } } else { installations.push(getInstallation({ savedObjectsClient, pkgName: resp.name })); } } const retrievedInstallations = await Promise.all(installations); - return retrievedInstallations.map((installation, index) => { + const verifiedInstallations = retrievedInstallations.map((installation, index) => { if (!installation) { throw new Error(`could not get installation ${bulkResponse[index].name}`); } return installation; }); + return { + installations: verifiedInstallations, + nonFatalPackageUpgradeErrors, + }; } -export async function isPackageVersionInstalled(options: { +async function isPackageVersionOrLaterInstalled(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; - pkgVersion?: string; + pkgVersion: string; }): Promise { const { savedObjectsClient, pkgName, pkgVersion } = options; const installedPackage = await getInstallation({ savedObjectsClient, pkgName }); - if (installedPackage && (!pkgVersion || installedPackage.version === pkgVersion)) { + if ( + installedPackage && + (installedPackage.version === pkgVersion || semverLt(pkgVersion, installedPackage.version)) + ) { return installedPackage; } return false; @@ -115,37 +113,31 @@ export async function ensureInstalledPackage(options: { pkgName: string; esClient: ElasticsearchClient; pkgVersion?: string; - force?: boolean; }): Promise { - const { savedObjectsClient, pkgName, esClient, pkgVersion, force } = options; - const installedPackage = await isPackageVersionInstalled({ + const { savedObjectsClient, pkgName, esClient, pkgVersion } = options; + + // If pkgVersion isn't specified, find the latest package version + const pkgKeyProps = pkgVersion + ? { name: pkgName, version: pkgVersion } + : await Registry.fetchFindLatestPackage(pkgName); + + const installedPackage = await isPackageVersionOrLaterInstalled({ savedObjectsClient, - pkgName, - pkgVersion, + pkgName: pkgKeyProps.name, + pkgVersion: pkgKeyProps.version, }); if (installedPackage) { return installedPackage; } - // if the requested packaged was not found to be installed, install - if (pkgVersion) { - const pkgkey = Registry.pkgToPkgKey({ - name: pkgName, - version: pkgVersion, - }); - await installPackage({ - installSource: 'registry', - savedObjectsClient, - pkgkey, - esClient, - force, - }); - } else { - await installLatestPackage({ - savedObjectsClient, - pkgName, - esClient, - }); - } + const pkgkey = Registry.pkgToPkgKey(pkgKeyProps); + await installPackage({ + installSource: 'registry', + savedObjectsClient, + pkgkey, + esClient, + force: true, // Always force outdated packages to be installed if a later version isn't installed + }); + const installation = await getInstallation({ savedObjectsClient, pkgName }); if (!installation) throw new Error(`could not get installation ${pkgName}`); return installation; @@ -208,6 +200,7 @@ export async function handleInstallPackageFailure({ export interface IBulkInstallPackageError { name: string; error: Error; + installType?: InstallType; } export type BulkInstallResponse = BulkInstallPackageInfo | IBulkInstallPackageError; @@ -228,54 +221,62 @@ async function installPackageFromRegistry({ // TODO: change epm API to /packageName/version so we don't need to do this const { pkgName, pkgVersion } = Registry.splitPkgKey(pkgkey); - // get the currently installed package - const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); - const installType = getInstallType({ pkgVersion, installedPkg }); + // if an error happens during getInstallType, report that we don't know + let installType: InstallType = 'unknown'; - // get latest package version - const latestPackage = await Registry.fetchFindLatestPackage(pkgName); + try { + // get the currently installed package + const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); + installType = getInstallType({ pkgVersion, installedPkg }); - // let the user install if using the force flag or needing to reinstall or install a previous version due to failed update - const installOutOfDateVersionOk = - force || ['reinstall', 'reupdate', 'rollback'].includes(installType); + // get latest package version + const latestPackage = await Registry.fetchFindLatestPackage(pkgName); - // if the requested version is the same as installed version, check if we allow it based on - // current installed package status and force flag, if we don't allow it, - // just return the asset references from the existing installation - if ( - installedPkg?.attributes.version === pkgVersion && - installedPkg?.attributes.install_status === 'installed' - ) { - if (!force) { - logger.debug(`${pkgkey} is already installed, skipping installation`); - return { - assets: [ - ...installedPkg.attributes.installed_es, - ...installedPkg.attributes.installed_kibana, - ], - status: 'already_installed', - }; + // let the user install if using the force flag or needing to reinstall or install a previous version due to failed update + const installOutOfDateVersionOk = + force || ['reinstall', 'reupdate', 'rollback'].includes(installType); + + // if the requested version is the same as installed version, check if we allow it based on + // current installed package status and force flag, if we don't allow it, + // just return the asset references from the existing installation + if ( + installedPkg?.attributes.version === pkgVersion && + installedPkg?.attributes.install_status === 'installed' + ) { + if (!force) { + logger.debug(`${pkgkey} is already installed, skipping installation`); + return { + assets: [ + ...installedPkg.attributes.installed_es, + ...installedPkg.attributes.installed_kibana, + ], + status: 'already_installed', + installType, + }; + } } - } - // if the requested version is out-of-date of the latest package version, check if we allow it - // if we don't allow it, return an error - if (semverLt(pkgVersion, latestPackage.version)) { - if (!installOutOfDateVersionOk) { - throw new PackageOutdatedError(`${pkgkey} is out-of-date and cannot be installed or updated`); + // if the requested version is out-of-date of the latest package version, check if we allow it + // if we don't allow it, return an error + if (semverLt(pkgVersion, latestPackage.version)) { + if (!installOutOfDateVersionOk) { + throw new PackageOutdatedError( + `${pkgkey} is out-of-date and cannot be installed or updated` + ); + } + logger.debug( + `${pkgkey} is out-of-date, installing anyway due to ${ + force ? 'force flag' : `install type ${installType}` + }` + ); } - logger.debug( - `${pkgkey} is out-of-date, installing anyway due to ${ - force ? 'force flag' : `install type ${installType}` - }` - ); - } - // get package info - const { paths, packageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion); + // get package info + const { paths, packageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion); - // try installing the package, if there was an error, call error handler and rethrow - try { + // try installing the package, if there was an error, call error handler and rethrow + // TODO: without the ts-ignore, TS complains about the type of the value of the returned InstallResult.status + // @ts-ignore return _installPackage({ savedObjectsClient, esClient, @@ -284,19 +285,26 @@ async function installPackageFromRegistry({ packageInfo, installType, installSource: 'registry', - }).then((assets) => { - return { assets, status: 'installed' }; - }); + }) + .then((assets) => { + return { assets, status: 'installed', installType }; + }) + .catch(async (err: Error) => { + await handleInstallPackageFailure({ + savedObjectsClient, + error: err, + pkgName, + pkgVersion, + installedPkg, + esClient, + }); + return { error: err, installType }; + }); } catch (e) { - await handleInstallPackageFailure({ - savedObjectsClient, + return { error: e, - pkgName, - pkgVersion, - installedPkg, - esClient, - }); - throw e; + installType, + }; } } @@ -313,46 +321,57 @@ async function installPackageByUpload({ archiveBuffer, contentType, }: InstallUploadedArchiveParams): Promise { - const { packageInfo } = await parseAndVerifyArchiveEntries(archiveBuffer, contentType); - - const installedPkg = await getInstallationObject({ - savedObjectsClient, - pkgName: packageInfo.name, - }); + // if an error happens during getInstallType, report that we don't know + let installType: InstallType = 'unknown'; + try { + const { packageInfo } = await parseAndVerifyArchiveEntries(archiveBuffer, contentType); - const installType = getInstallType({ pkgVersion: packageInfo.version, installedPkg }); - if (installType !== 'install') { - throw new PackageOperationNotSupportedError( - `Package upload only supports fresh installations. Package ${packageInfo.name} is already installed, please uninstall first.` - ); - } + const installedPkg = await getInstallationObject({ + savedObjectsClient, + pkgName: packageInfo.name, + }); - const installSource = 'upload'; - const paths = await unpackBufferToCache({ - name: packageInfo.name, - version: packageInfo.version, - installSource, - archiveBuffer, - contentType, - }); + installType = getInstallType({ pkgVersion: packageInfo.version, installedPkg }); + if (installType !== 'install') { + throw new PackageOperationNotSupportedError( + `Package upload only supports fresh installations. Package ${packageInfo.name} is already installed, please uninstall first.` + ); + } - setPackageInfo({ - name: packageInfo.name, - version: packageInfo.version, - packageInfo, - }); + const installSource = 'upload'; + const paths = await unpackBufferToCache({ + name: packageInfo.name, + version: packageInfo.version, + installSource, + archiveBuffer, + contentType, + }); - return _installPackage({ - savedObjectsClient, - esClient, - installedPkg, - paths, - packageInfo, - installType, - installSource, - }).then((assets) => { - return { assets, status: 'installed' }; - }); + setPackageInfo({ + name: packageInfo.name, + version: packageInfo.version, + packageInfo, + }); + // TODO: without the ts-ignore, TS complains about the type of the value of the returned InstallResult.status + // @ts-ignore + return _installPackage({ + savedObjectsClient, + esClient, + installedPkg, + paths, + packageInfo, + installType, + installSource, + }) + .then((assets) => { + return { assets, status: 'installed', installType }; + }) + .catch(async (err: Error) => { + return { error: err, installType }; + }); + } catch (e) { + return { error: e, installType }; + } } export type InstallPackageParams = { @@ -379,7 +398,7 @@ export async function installPackage(args: InstallPackageParams) { esClient, force, }).then(async (installResult) => { - if (skipPostInstall) { + if (skipPostInstall || installResult.error) { return installResult; } logger.debug(`install of ${pkgkey} finished, running post-install`); @@ -401,7 +420,7 @@ export async function installPackage(args: InstallPackageParams) { archiveBuffer, contentType, }).then(async (installResult) => { - if (skipPostInstall) { + if (skipPostInstall || installResult.error) { return installResult; } logger.debug(`install of uploaded package finished, running post-install`); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index de798e822b029..706f1bbbaaf35 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -79,6 +79,7 @@ export async function removeInstallation(options: { return installedAssets; } +// TODO: this is very much like deleteKibanaSavedObjectsAssets below function deleteKibanaAssets( installedObjects: KibanaAssetReference[], savedObjectsClient: SavedObjectsClientContract @@ -136,6 +137,7 @@ async function deleteTemplate(esClient: ElasticsearchClient, name: string): Prom } } +// TODO: this is very much like deleteKibanaAssets above export async function deleteKibanaSavedObjectsAssets( savedObjectsClient: SavedObjectsClientContract, installedRefs: AssetReference[] @@ -153,6 +155,9 @@ export async function deleteKibanaSavedObjectsAssets( try { await Promise.all(deletePromises); } catch (err) { - logger.warn(err); + // in the rollback case, partial installs are likely, so missing assets are not an error + if (!savedObjectsClient.errors.isNotFoundError(err)) { + logger.error(err); + } } } diff --git a/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts b/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts index df8aa7cb01286..82fd937092477 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts @@ -23,15 +23,11 @@ import type { } from '../../../common'; import { listEnrollmentApiKeys, getEnrollmentAPIKey } from '../api_keys/enrollment_api_key_so'; import { appContextService } from '../app_context'; -import { isAgentsSetup } from '../agents'; import { agentPolicyService } from '../agent_policy'; import { invalidateAPIKeys } from '../api_keys'; +import { settingsService } from '..'; export async function runFleetServerMigration() { - // If Agents are not setup skip as there is nothing to migrate - if (!(await isAgentsSetup(getInternalUserSOClient()))) { - return; - } await Promise.all([migrateEnrollmentApiKeys(), migrateAgentPolicies(), migrateAgents()]); } @@ -59,6 +55,9 @@ async function migrateAgents() { const soClient = getInternalUserSOClient(); const logger = appContextService.getLogger(); let hasMore = true; + + let hasAgents = false; + while (hasMore) { const res = await soClient.find({ type: AGENT_SAVED_OBJECT_TYPE, @@ -68,7 +67,10 @@ async function migrateAgents() { if (res.total === 0) { hasMore = false; + } else { + hasAgents = true; } + for (const so of res.saved_objects) { try { const { @@ -120,6 +122,13 @@ async function migrateAgents() { } } } + + // Update settings to show migration modal + if (hasAgents) { + await settingsService.saveSettings(soClient, { + has_seen_fleet_migration_notice: false, + }); + } } async function migrateEnrollmentApiKeys() { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 0857338469794..7c009299a3de3 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -76,7 +76,7 @@ class PackagePolicyService { } if (parentAgentPolicy.is_managed && !options?.force) { throw new IngestManagerError( - `Cannot add integrations to managed policy ${parentAgentPolicy.id}` + `Cannot add integrations to hosted agent policy ${parentAgentPolicy.id}` ); } if ( diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 4bdd473e077f4..d60b8fde2aa8d 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -117,12 +117,6 @@ jest.mock('./package_policy', () => ({ }, })); -jest.mock('./agents/setup', () => ({ - isAgentsSetup() { - return false; - }, -})); - describe('policy preconfiguration', () => { beforeEach(() => { mockInstalledPackages.clear(); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 3bd3169673b31..ffb16d286c45a 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -19,7 +19,10 @@ import type { PreconfiguredAgentPolicy, PreconfiguredPackage, } from '../../common'; -import { PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE } from '../constants'; +import { + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, + PRECONFIGURATION_LATEST_KEYWORD, +} from '../constants'; import { escapeSearchQueryPhrase } from './saved_object'; @@ -64,8 +67,8 @@ export async function ensurePreconfiguredPackagesAndPolicies( // Preinstall packages specified in Kibana config const preconfiguredPackages = await Promise.all( - packages.map(({ name, version, force }) => - ensureInstalledPreconfiguredPackage(soClient, esClient, name, version, force) + packages.map(({ name, version }) => + ensureInstalledPreconfiguredPackage(soClient, esClient, name, version) ) ); @@ -202,15 +205,14 @@ async function ensureInstalledPreconfiguredPackage( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, pkgName: string, - pkgVersion: string, - force?: boolean + pkgVersion: string ) { + const isLatest = pkgVersion === PRECONFIGURATION_LATEST_KEYWORD; return ensureInstalledPackage({ savedObjectsClient: soClient, pkgName, esClient, - pkgVersion, - force, + pkgVersion: isLatest ? undefined : pkgVersion, }); } diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 7658a8d71839e..e0723a8e16306 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -5,12 +5,10 @@ * 2.0. */ -import url from 'url'; - import Boom from '@hapi/boom'; import type { SavedObjectsClientContract } from 'kibana/server'; -import { GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, decodeCloudId } from '../../common'; +import { GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '../../common'; import type { SettingsSOAttributes, Settings, BaseSettings } from '../../common'; import { appContextService } from './app_context'; @@ -67,25 +65,9 @@ export async function saveSettings( } export function createDefaultSettings(): BaseSettings { - const http = appContextService.getHttpSetup(); - const serverInfo = http.getServerInfo(); - const basePath = http.basePath; - - const cloud = appContextService.getCloud(); - const cloudId = cloud?.isCloudEnabled && cloud.cloudId; - const cloudUrl = cloudId && decodeCloudId(cloudId)?.kibanaUrl; - const flagsUrl = appContextService.getConfig()?.agents?.kibana?.host; - const defaultUrl = url.format({ - protocol: serverInfo.protocol, - hostname: serverInfo.hostname, - port: serverInfo.port, - pathname: basePath.serverBasePath, - }); - const fleetServerHosts = appContextService.getConfig()?.agents?.fleet_server?.hosts ?? []; return { - kibana_urls: [cloudUrl || flagsUrl || defaultUrl].flat(), fleet_server_hosts: fleetServerHosts, }; } diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index de551a584f49f..de6876c7f6fda 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -5,13 +5,12 @@ * 2.0. */ -import uuid from 'uuid'; import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { i18n } from '@kbn/i18n'; import { DEFAULT_AGENT_POLICIES_PACKAGES, FLEET_SERVER_PACKAGE } from '../../common'; -import type { PackagePolicy } from '../../common'; +import type { PackagePolicy, DefaultPackagesInstallationError } from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; @@ -31,12 +30,10 @@ import { createDefaultSettings } from './settings'; import { ensureAgentActionPolicyChangeExists } from './agents'; import { awaitIfFleetServerSetupPending } from './fleet_server'; -const FLEET_ENROLL_USERNAME = 'fleet_enroll'; -const FLEET_ENROLL_ROLE = 'fleet_enroll'; - export interface SetupStatus { isInitialized: boolean; preconfigurationError: { name: string; message: string } | undefined; + nonFatalPackageUpgradeErrors: DefaultPackagesInstallationError[]; } export async function setupIngestManager( @@ -50,7 +47,7 @@ async function createSetupSideEffects( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient ): Promise { - const [installedPackages, defaultOutput] = await Promise.all([ + const [defaultPackagesResult, defaultOutput] = await Promise.all([ // packages installed by default ensureInstalledDefaultPackages(soClient, esClient), outputService.ensureDefaultOutput(soClient), @@ -146,7 +143,7 @@ async function createSetupSideEffects( ); } - for (const installedPackage of installedPackages) { + for (const installedPackage of defaultPackagesResult.installations) { const packageShouldBeInstalled = DEFAULT_AGENT_POLICIES_PACKAGES.some( (packageName) => installedPackage.name === packageName ); @@ -176,7 +173,11 @@ async function createSetupSideEffects( await ensureAgentActionPolicyChangeExists(soClient, esClient); - return { isInitialized: true, preconfigurationError }; + return { + isInitialized: true, + preconfigurationError, + nonFatalPackageUpgradeErrors: defaultPackagesResult.nonFatalPackageUpgradeErrors, + }; } export async function ensureDefaultEnrollmentAPIKeysExists( @@ -213,66 +214,3 @@ export async function ensureDefaultEnrollmentAPIKeysExists( }) ); } - -async function putFleetRole(esClient: ElasticsearchClient) { - return await esClient.security.putRole({ - name: FLEET_ENROLL_ROLE, - body: { - cluster: ['monitor', 'manage_api_key'], - indices: [ - { - names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], - privileges: ['auto_configure', 'create_doc'], - }, - ], - }, - }); -} - -// TODO Deprecated should be removed as part of https://github.com/elastic/kibana/issues/94303 -export async function setupFleet( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - options?: { forceRecreate?: boolean } -) { - // Create fleet_enroll role - // This should be done directly in ES at some point - const { body: res } = await putFleetRole(esClient); - - // If the role is already created skip the rest unless you have forceRecreate set to true - if (options?.forceRecreate !== true && res.role.created === false) { - return; - } - const password = generateRandomPassword(); - // Create fleet enroll user - await esClient.security.putUser({ - username: FLEET_ENROLL_USERNAME, - body: { - password, - roles: [FLEET_ENROLL_ROLE], - metadata: { - updated_at: new Date().toISOString(), - }, - }, - }); - - // save fleet admin user - const defaultOutputId = await outputService.getDefaultOutputId(soClient); - if (!defaultOutputId) { - throw new Error( - i18n.translate('xpack.fleet.setup.defaultOutputError', { - defaultMessage: 'Default output does not exist', - }) - ); - } - await outputService.updateOutput(soClient, defaultOutputId, { - fleet_enroll_username: FLEET_ENROLL_USERNAME, - fleet_enroll_password: password, - }); - - outputService.invalidateCache(); -} - -function generateRandomPassword() { - return Buffer.from(uuid.v4()).toString('base64'); -} diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 581a8241f09bf..87808e03fe70b 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -74,9 +74,6 @@ export { InstallType, InstallSource, InstallResult, - // Agent Request types - PostAgentEnrollRequest, - PostAgentCheckinRequest, DataType, dataTypes, // Fleet Server types diff --git a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts index f697e436fcf4a..11336af6c2635 100644 --- a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts @@ -8,6 +8,8 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import semverValid from 'semver/functions/valid'; +import { PRECONFIGURATION_LATEST_KEYWORD } from '../../constants'; + import { AgentPolicyBaseSchema } from './agent_policy'; import { NamespaceSchema } from './package_policy'; @@ -27,14 +29,13 @@ export const PreconfiguredPackagesSchema = schema.arrayOf( name: schema.string(), version: schema.string({ validate: (value) => { - if (!semverValid(value)) { + if (value !== PRECONFIGURATION_LATEST_KEYWORD && !semverValid(value)) { return i18n.translate('xpack.fleet.config.invalidPackageVersionError', { - defaultMessage: 'must be a valid semver', + defaultMessage: 'must be a valid semver, or the keyword `latest`', }); } }, }), - force: schema.maybe(schema.boolean()), }) ); diff --git a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts index 9051d7a06efff..fbadac2353b02 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts @@ -22,6 +22,10 @@ export const PutSettingsRequestSchema = { }, }) ), + has_seen_add_data_notice: schema.maybe(schema.boolean()), + has_seen_fleet_migration_notice: schema.maybe(schema.boolean()), + additional_yaml_config: schema.maybe(schema.string()), + // Deprecated not used kibana_urls: schema.maybe( schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { validate: (value) => { @@ -32,7 +36,5 @@ export const PutSettingsRequestSchema = { }) ), kibana_ca_sha256: schema.maybe(schema.string()), - has_seen_add_data_notice: schema.maybe(schema.boolean()), - additional_yaml_config: schema.maybe(schema.string()), }), }; diff --git a/x-pack/plugins/graph/public/components/field_manager/_field_editor.scss b/x-pack/plugins/graph/public/components/field_manager/_field_editor.scss index 20773d98ce9c3..867899fc66391 100644 --- a/x-pack/plugins/graph/public/components/field_manager/_field_editor.scss +++ b/x-pack/plugins/graph/public/components/field_manager/_field_editor.scss @@ -1,11 +1,21 @@ .gphFieldEditor__badge { @include gphFieldBadgeSize; -} -.gphFieldEditor__badge--disabled, -.gphFieldEditor__badge--disabled:focus { - opacity: .7; - text-decoration: line-through; + &.gphFieldEditor__badge--disabled, + &.gphFieldEditor__badge--disabled:hover, + &.gphFieldEditor__badge--disabled:focus, + &.gphFieldEditor__badge--disabled:not(:disabled):hover + &.gphFieldEditor__badge--disabled:not(:disabled):focus { + opacity: .7; + text-decoration: line-through; + } + + // Chrome fix for focus: duplicate or Safari will ignore completely the disabled rule + &.gphFieldEditor__badge--disabled:focus:not(:focus-visible), + &.gphFieldEditor__badge--disabled:hover:not(:focus-visible) { + opacity: .7; + text-decoration: line-through; + } } .gphFieldEditor__badgeIcon { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/components/max_index_size_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/components/max_index_size_field.tsx index 78f3c74c9cb82..195acf35c1357 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/components/max_index_size_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/components/max_index_size_field.tsx @@ -40,7 +40,13 @@ export const MaxIndexSizeField: FunctionComponent = () => { componentProps={{ euiFieldProps: { 'data-test-subj': 'hot-selectedMaxSizeStored', - prepend: , + prepend: ( + + ), min: 1, }, }} diff --git a/x-pack/plugins/infra/common/dependency_mocks/index_patterns.ts b/x-pack/plugins/infra/common/dependency_mocks/index_patterns.ts new file mode 100644 index 0000000000000..14215c1539473 --- /dev/null +++ b/x-pack/plugins/infra/common/dependency_mocks/index_patterns.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { from, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { + fieldList, + FieldSpec, + IIndexPattern, + IndexPattern, + IndexPatternsContract, + RuntimeField, +} from 'src/plugins/data/common'; + +type IndexPatternMock = Pick< + IndexPattern, + | 'fields' + | 'getComputedFields' + | 'getFieldByName' + | 'getTimeField' + | 'id' + | 'isTimeBased' + | 'title' + | 'type' +>; +type IndexPatternMockSpec = Pick & { + fields: FieldSpec[]; +}; + +export const createIndexPatternMock = ({ + id, + title, + type = undefined, + fields, + timeFieldName, +}: IndexPatternMockSpec): IndexPatternMock => { + const indexPatternFieldList = fieldList(fields); + + return { + id, + title, + type, + fields: indexPatternFieldList, + getTimeField: () => indexPatternFieldList.find(({ name }) => name === timeFieldName), + isTimeBased: () => timeFieldName != null, + getFieldByName: (fieldName) => indexPatternFieldList.find(({ name }) => name === fieldName), + getComputedFields: () => ({ + runtimeFields: indexPatternFieldList.reduce>( + (accumulatedFields, { name, runtimeField }) => ({ + ...accumulatedFields, + ...(runtimeField != null + ? { + [name]: runtimeField, + } + : {}), + }), + {} + ), + scriptFields: {}, + storedFields: [], + docvalueFields: [], + }), + }; +}; + +export const createIndexPatternsMock = ( + asyncDelay: number, + indexPatterns: IndexPatternMock[] +): { + getIdsWithTitle: IndexPatternsContract['getIdsWithTitle']; + get: (...args: Parameters) => Promise; +} => { + return { + async getIdsWithTitle(_refresh?: boolean) { + const indexPatterns$ = of( + indexPatterns.map(({ id = 'unknown_id', title }) => ({ id, title })) + ); + return await indexPatterns$.pipe(delay(asyncDelay)).toPromise(); + }, + async get(indexPatternId: string) { + const indexPatterns$ = from( + indexPatterns.filter((indexPattern) => indexPattern.id === indexPatternId) + ); + return await indexPatterns$.pipe(delay(asyncDelay)).toPromise(); + }, + }; +}; + +export const createIndexPatternsStartMock = ( + asyncDelay: number, + indexPatterns: IndexPatternMock[] +): any => { + return { + indexPatternsServiceFactory: async () => createIndexPatternsMock(asyncDelay, indexPatterns), + }; +}; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/validation/datasets.ts b/x-pack/plugins/infra/common/http_api/log_analysis/validation/datasets.ts index c349ceab03043..ff4ee4fd328da 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/validation/datasets.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/validation/datasets.ts @@ -19,6 +19,7 @@ export const validateLogEntryDatasetsRequestPayloadRT = rt.type({ timestampField: rt.string, startTime: rt.number, endTime: rt.number, + runtimeMappings: rt.UnknownRecord, }), }); diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/validation/log_entry_rate_indices.ts b/x-pack/plugins/infra/common/http_api/log_analysis/validation/log_entry_rate_indices.ts index c63a544201749..a6a7a9996d260 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/validation/log_entry_rate_indices.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/validation/log_entry_rate_indices.ts @@ -26,6 +26,7 @@ export const validationIndicesRequestPayloadRT = rt.type({ data: rt.type({ fields: rt.array(validationIndicesFieldSpecificationRT), indices: rt.array(rt.string), + runtimeMappings: rt.UnknownRecord, }), }); diff --git a/x-pack/plugins/infra/common/log_sources/log_source_configuration.ts b/x-pack/plugins/infra/common/log_sources/log_source_configuration.ts index 83bc8743900eb..ab98ad75b8433 100644 --- a/x-pack/plugins/infra/common/log_sources/log_source_configuration.ts +++ b/x-pack/plugins/infra/common/log_sources/log_source_configuration.ts @@ -53,18 +53,21 @@ export const logSourceColumnConfigurationRT = rt.union([ export type LogSourceColumnConfiguration = rt.TypeOf; // Kibana index pattern -const logIndexPatternReferenceRT = rt.type({ +export const logIndexPatternReferenceRT = rt.type({ type: rt.literal('index_pattern'), indexPatternId: rt.string, }); +export type LogIndexPatternReference = rt.TypeOf; // Legacy support -const logIndexNameReferenceRT = rt.type({ +export const logIndexNameReferenceRT = rt.type({ type: rt.literal('index_name'), indexName: rt.string, }); +export type LogIndexNameReference = rt.TypeOf; export const logIndexReferenceRT = rt.union([logIndexPatternReferenceRT, logIndexNameReferenceRT]); +export type LogIndexReference = rt.TypeOf; export const logSourceConfigurationPropertiesRT = rt.strict({ name: rt.string, diff --git a/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts b/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts index 8bc7eee7d4eb6..daac7f6a138eb 100644 --- a/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts +++ b/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts @@ -5,11 +5,13 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; +import { IndexPattern, IndexPatternsContract } from '../../../../../src/plugins/data/common'; +import { ObjectEntries } from '../utility_types'; import { - LogSourceConfigurationProperties, LogSourceColumnConfiguration, + LogSourceConfigurationProperties, } from './log_source_configuration'; -import { IndexPatternsContract, IndexPattern } from '../../../../../src/plugins/data/common'; export interface ResolvedLogSourceConfiguration { name: string; @@ -19,6 +21,7 @@ export interface ResolvedLogSourceConfiguration { tiebreakerField: string; messageField: string[]; fields: IndexPattern['fields']; + runtimeMappings: estypes.RuntimeFields; columns: LogSourceColumnConfiguration[]; } @@ -52,6 +55,7 @@ const resolveLegacyReference = async ( tiebreakerField: sourceConfiguration.fields.tiebreaker, messageField: sourceConfiguration.fields.message, fields, + runtimeMappings: {}, columns: sourceConfiguration.logColumns, name: sourceConfiguration.name, description: sourceConfiguration.description, @@ -76,8 +80,36 @@ const resolveKibanaIndexPatternReference = async ( tiebreakerField: '_doc', messageField: ['message'], fields: indexPattern.fields, + runtimeMappings: resolveRuntimeMappings(indexPattern), columns: sourceConfiguration.logColumns, name: sourceConfiguration.name, description: sourceConfiguration.description, }; }; + +// this might take other sources of runtime fields into account in the future +const resolveRuntimeMappings = (indexPattern: IndexPattern): estypes.RuntimeFields => { + const { runtimeFields } = indexPattern.getComputedFields(); + + const runtimeMappingsFromIndexPattern = (Object.entries(runtimeFields) as ObjectEntries< + typeof runtimeFields + >).reduce( + (accumulatedMappings, [runtimeFieldName, runtimeFieldSpec]) => ({ + ...accumulatedMappings, + [runtimeFieldName]: { + type: runtimeFieldSpec.type, + ...(runtimeFieldSpec.script != null + ? { + script: { + lang: 'painless', // required in the es types + source: runtimeFieldSpec.script.source, + }, + } + : {}), + }, + }), + {} + ); + + return runtimeMappingsFromIndexPattern; +}; diff --git a/x-pack/plugins/infra/common/utility_types.ts b/x-pack/plugins/infra/common/utility_types.ts index a785b4d13e557..1f26309973f0d 100644 --- a/x-pack/plugins/infra/common/utility_types.ts +++ b/x-pack/plugins/infra/common/utility_types.ts @@ -45,5 +45,7 @@ interface DeepPartialArray extends Array> {} type DeepPartialObject = { [P in keyof T]+?: DeepPartial }; +export type ObjectValues = Array; + export type ObjectEntry = [keyof T, T[keyof T]]; export type ObjectEntries = Array>; diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts index 4b9b2f99215b7..1c7e8ceb28fb4 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts @@ -40,6 +40,16 @@ export class LogStreamEmbeddableFactoryDefinition }); } + public getDescription() { + return i18n.translate('xpack.infra.logStreamEmbeddable.description', { + defaultMessage: 'Add a table of live streaming logs.', + }); + } + + public getIconType() { + return 'logsApp'; + } + public async getExplicitInput() { return { title: i18n.translate('xpack.infra.logStreamEmbeddable.title', { diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts index 9b827b6cb5331..d4e1f7366dd2a 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts @@ -97,6 +97,9 @@ export const jobSummaryRT = rt.intersection([ custom_settings: jobCustomSettingsRT, finished_time: rt.number, model_size_stats: jobModelSizeStatsRT, + datafeed_config: rt.partial({ + runtime_mappings: rt.UnknownRecord, + }), }), }), ]); diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_datasets.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_datasets.ts index 8fe2d215cef26..9eadc3035588d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_datasets.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_datasets.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import type { HttpHandler } from 'src/core/public'; import { LOG_ANALYSIS_VALIDATE_DATASETS_PATH, @@ -18,10 +19,11 @@ interface RequestArgs { timestampField: string; startTime: number; endTime: number; + runtimeMappings: estypes.RuntimeFields; } export const callValidateDatasetsAPI = async (requestArgs: RequestArgs, fetch: HttpHandler) => { - const { indices, timestampField, startTime, endTime } = requestArgs; + const { indices, timestampField, startTime, endTime, runtimeMappings } = requestArgs; const response = await fetch(LOG_ANALYSIS_VALIDATE_DATASETS_PATH, { method: 'POST', body: JSON.stringify( @@ -31,6 +33,7 @@ export const callValidateDatasetsAPI = async (requestArgs: RequestArgs, fetch: H indices, startTime, timestampField, + runtimeMappings, }, }) ), diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_indices.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_indices.ts index 5168736b80f0a..f9eb7609e00f3 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_indices.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_indices.ts @@ -6,6 +6,7 @@ */ import type { HttpHandler } from 'src/core/public'; +import { estypes } from '@elastic/elasticsearch'; import { LOG_ANALYSIS_VALIDATE_INDICES_PATH, @@ -19,13 +20,16 @@ import { decodeOrThrow } from '../../../../../common/runtime_types'; interface RequestArgs { indices: string[]; fields: ValidationIndicesFieldSpecification[]; + runtimeMappings: estypes.RuntimeFields; } export const callValidateIndicesAPI = async (requestArgs: RequestArgs, fetch: HttpHandler) => { - const { indices, fields } = requestArgs; + const { indices, fields, runtimeMappings } = requestArgs; const response = await fetch(LOG_ANALYSIS_VALIDATE_INDICES_PATH, { method: 'POST', - body: JSON.stringify(validationIndicesRequestPayloadRT.encode({ data: { indices, fields } })), + body: JSON.stringify( + validationIndicesRequestPayloadRT.encode({ data: { indices, fields, runtimeMappings } }) + ), }); return decodeOrThrow(validationIndicesResponsePayloadRT)(response); diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx index 00a6c3c2a72fb..a9ea7e6d6e39a 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx @@ -21,7 +21,7 @@ export const useLogAnalysisModule = ({ moduleDescriptor: ModuleDescriptor; }) => { const { services } = useKibanaContextForPlugin(); - const { spaceId, sourceId, timestampField } = sourceConfiguration; + const { spaceId, sourceId, timestampField, runtimeMappings } = sourceConfiguration; const [moduleStatus, dispatchModuleStatus] = useModuleStatus(moduleDescriptor.jobTypes); const trackMetric = useUiTracker({ app: 'infra_logs' }); @@ -67,6 +67,7 @@ export const useLogAnalysisModule = ({ sourceId, spaceId, timestampField, + runtimeMappings, }, services.http.fetch ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_configuration.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_configuration.ts index 1a1f2862b331b..888c89357929a 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_configuration.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_configuration.ts @@ -6,6 +6,7 @@ */ import { useMemo } from 'react'; +import equal from 'fast-deep-equal'; import { JobSummary } from './api/ml_get_jobs_summary_api'; import { ModuleDescriptor, ModuleSourceConfiguration } from './log_analysis_module_types'; @@ -30,11 +31,16 @@ export const isJobConfigurationOutdated = ( { bucketSpan }: ModuleDescriptor, currentSourceConfiguration: ModuleSourceConfiguration ) => (jobSummary: JobSummary): boolean => { - if (!jobSummary.fullJob || !jobSummary.fullJob.custom_settings) { + if ( + !jobSummary.fullJob || + !jobSummary.fullJob.custom_settings || + !jobSummary.fullJob.datafeed_config + ) { return false; } const jobConfiguration = jobSummary.fullJob.custom_settings.logs_source_config; + const datafeedRuntimeMappings = jobSummary.fullJob.datafeed_config.runtime_mappings; return !( jobConfiguration && @@ -44,7 +50,8 @@ export const isJobConfigurationOutdated = ( new Set(jobConfiguration.indexPattern.split(',')), new Set(currentSourceConfiguration.indices) ) && - jobConfiguration.timestampField === currentSourceConfiguration.timestampField + jobConfiguration.timestampField === currentSourceConfiguration.timestampField && + equal(datafeedRuntimeMappings, currentSourceConfiguration.runtimeMappings) ); }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts index e79b75fecc817..36371b080ee45 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts @@ -6,6 +6,7 @@ */ import type { HttpHandler } from 'src/core/public'; +import { estypes } from '@elastic/elasticsearch'; import { ValidateLogEntryDatasetsResponsePayload, ValidationIndicesResponsePayload, @@ -46,6 +47,7 @@ export interface ModuleDescriptor { validateSetupIndices: ( indices: string[], timestampField: string, + runtimeMappings: estypes.RuntimeFields, fetch: HttpHandler ) => Promise; validateSetupDatasets: ( @@ -53,6 +55,7 @@ export interface ModuleDescriptor { timestampField: string, startTime: number, endTime: number, + runtimeMappings: estypes.RuntimeFields, fetch: HttpHandler ) => Promise; } @@ -62,4 +65,5 @@ export interface ModuleSourceConfiguration { sourceId: string; spaceId: string; timestampField: string; + runtimeMappings: estypes.RuntimeFields; } diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.ts index 825ac5be747fe..fad6fd56f6251 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.ts @@ -162,6 +162,7 @@ export const useAnalysisSetupState = ({ return await validateSetupIndices( sourceConfiguration.indices, sourceConfiguration.timestampField, + sourceConfiguration.runtimeMappings, services.http.fetch ); }, @@ -188,6 +189,7 @@ export const useAnalysisSetupState = ({ sourceConfiguration.timestampField, startTime ?? 0, endTime ?? Date.now(), + sourceConfiguration.runtimeMappings, services.http.fetch ); }, diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts index bc79dbdf0912a..981b7b496b435 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import { i18n } from '@kbn/i18n'; import type { HttpHandler } from 'src/core/public'; import { @@ -62,7 +63,7 @@ const setUpModule = async ( start: number | undefined, end: number | undefined, datasetFilter: DatasetFilter, - { spaceId, sourceId, indices, timestampField }: ModuleSourceConfiguration, + { spaceId, sourceId, indices, timestampField, runtimeMappings }: ModuleSourceConfiguration, fetch: HttpHandler ) => { const indexNamePattern = indices.join(','); @@ -85,6 +86,12 @@ const setUpModule = async ( }, }, ]; + const datafeedOverrides = [ + { + job_id: 'log-entry-categories-count' as const, + runtime_mappings: runtimeMappings, + }, + ]; const query = { bool: { filter: [ @@ -115,6 +122,7 @@ const setUpModule = async ( sourceId, indexPattern: indexNamePattern, jobOverrides, + datafeedOverrides, query, }, fetch @@ -128,6 +136,7 @@ const cleanUpModule = async (spaceId: string, sourceId: string, fetch: HttpHandl const validateSetupIndices = async ( indices: string[], timestampField: string, + runtimeMappings: estypes.RuntimeFields, fetch: HttpHandler ) => { return await callValidateIndicesAPI( @@ -147,6 +156,7 @@ const validateSetupIndices = async ( validTypes: ['text'], }, ], + runtimeMappings, }, fetch ); @@ -157,9 +167,13 @@ const validateSetupDatasets = async ( timestampField: string, startTime: number, endTime: number, + runtimeMappings: estypes.RuntimeFields, fetch: HttpHandler ) => { - return await callValidateDatasetsAPI({ indices, timestampField, startTime, endTime }, fetch); + return await callValidateDatasetsAPI( + { indices, timestampField, startTime, endTime, runtimeMappings }, + fetch + ); }; export const logEntryCategoriesModule: ModuleDescriptor = { diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx index eaa82dd18c984..a2ad5cd4f56c4 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx @@ -6,6 +6,7 @@ */ import createContainer from 'constate'; +import { estypes } from '@elastic/elasticsearch'; import { useMemo } from 'react'; import { useLogAnalysisModule } from '../../log_analysis_module'; import { useLogAnalysisModuleConfiguration } from '../../log_analysis_module_configuration'; @@ -19,11 +20,13 @@ export const useLogEntryCategoriesModule = ({ sourceId, spaceId, timestampField, + runtimeMappings, }: { indexPattern: string; sourceId: string; spaceId: string; timestampField: string; + runtimeMappings: estypes.RuntimeFields; }) => { const sourceConfiguration: ModuleSourceConfiguration = useMemo( () => ({ @@ -31,8 +34,9 @@ export const useLogEntryCategoriesModule = ({ sourceId, spaceId, timestampField, + runtimeMappings, }), - [indexPattern, sourceId, spaceId, timestampField] + [indexPattern, sourceId, spaceId, timestampField, runtimeMappings] ); const logAnalysisModule = useLogAnalysisModule({ diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts index f7c866c8e4e67..345f221f11c1f 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import type { estypes } from '@elastic/elasticsearch'; import type { HttpHandler } from 'src/core/public'; import { bucketSpan, @@ -61,7 +62,7 @@ const setUpModule = async ( start: number | undefined, end: number | undefined, datasetFilter: DatasetFilter, - { spaceId, sourceId, indices, timestampField }: ModuleSourceConfiguration, + { spaceId, sourceId, indices, timestampField, runtimeMappings }: ModuleSourceConfiguration, fetch: HttpHandler ) => { const indexNamePattern = indices.join(','); @@ -83,6 +84,12 @@ const setUpModule = async ( }, }, ]; + const datafeedOverrides = [ + { + job_id: 'log-entry-rate' as const, + runtime_mappings: runtimeMappings, + }, + ]; const query = datasetFilter.type === 'includeSome' ? { @@ -107,6 +114,7 @@ const setUpModule = async ( sourceId, indexPattern: indexNamePattern, jobOverrides, + datafeedOverrides, query, }, fetch @@ -120,6 +128,7 @@ const cleanUpModule = async (spaceId: string, sourceId: string, fetch: HttpHandl const validateSetupIndices = async ( indices: string[], timestampField: string, + runtimeMappings: estypes.RuntimeFields, fetch: HttpHandler ) => { return await callValidateIndicesAPI( @@ -135,6 +144,7 @@ const validateSetupIndices = async ( validTypes: ['keyword'], }, ], + runtimeMappings, }, fetch ); @@ -145,9 +155,13 @@ const validateSetupDatasets = async ( timestampField: string, startTime: number, endTime: number, + runtimeMappings: estypes.RuntimeFields, fetch: HttpHandler ) => { - return await callValidateDatasetsAPI({ indices, timestampField, startTime, endTime }, fetch); + return await callValidateDatasetsAPI( + { indices, timestampField, startTime, endTime, runtimeMappings }, + fetch + ); }; export const logEntryRateModule: ModuleDescriptor = { diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx index 02eeb66f44590..b451cad1c8753 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import createContainer from 'constate'; import { useMemo } from 'react'; import { ModuleSourceConfiguration } from '../../log_analysis_module_types'; @@ -18,11 +19,13 @@ export const useLogEntryRateModule = ({ sourceId, spaceId, timestampField, + runtimeMappings, }: { indexPattern: string; sourceId: string; spaceId: string; timestampField: string; + runtimeMappings: estypes.RuntimeFields; }) => { const sourceConfiguration: ModuleSourceConfiguration = useMemo( () => ({ @@ -30,8 +33,9 @@ export const useLogEntryRateModule = ({ sourceId, spaceId, timestampField, + runtimeMappings, }), - [indexPattern, sourceId, spaceId, timestampField] + [indexPattern, sourceId, spaceId, timestampField, runtimeMappings] ); const logAnalysisModule = useLogAnalysisModule({ diff --git a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx new file mode 100644 index 0000000000000..dbf032415cb99 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { from, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { CoreStart } from '../../../../../src/core/public'; +import { FieldSpec } from '../../../../../src/plugins/data/common'; +import { + IIndexPattern, + IndexPattern, + IndexPatternField, + IndexPatternsContract, +} from '../../../../../src/plugins/data/public'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { Pick2 } from '../../common/utility_types'; + +type MockIndexPattern = Pick< + IndexPattern, + 'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName' +>; +export type MockIndexPatternSpec = Pick< + IIndexPattern, + 'id' | 'title' | 'type' | 'timeFieldName' +> & { + fields: FieldSpec[]; +}; + +export const MockIndexPatternsKibanaContextProvider: React.FC<{ + asyncDelay: number; + mockIndexPatterns: MockIndexPatternSpec[]; +}> = ({ asyncDelay, children, mockIndexPatterns }) => { + const indexPatterns = useMemo( + () => + createIndexPatternsMock( + asyncDelay, + mockIndexPatterns.map(({ id, title, type = undefined, fields, timeFieldName }) => { + const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec)); + + return { + id, + title, + type, + getTimeField: () => indexPatternFields.find(({ name }) => name === timeFieldName), + isTimeBased: () => timeFieldName != null, + getFieldByName: (fieldName) => + indexPatternFields.find(({ name }) => name === fieldName), + }; + }) + ), + [asyncDelay, mockIndexPatterns] + ); + + const core = useMemo>( + () => ({ + application: { + getUrlForApp: () => '', + }, + }), + [] + ); + + return ( + + {children} + + ); +}; + +const createIndexPatternsMock = ( + asyncDelay: number, + indexPatterns: MockIndexPattern[] +): { + getIdsWithTitle: IndexPatternsContract['getIdsWithTitle']; + get: (...args: Parameters) => Promise; +} => { + return { + async getIdsWithTitle(_refresh?: boolean) { + const indexPatterns$ = of( + indexPatterns.map(({ id = 'unknown_id', title }) => ({ id, title })) + ); + return await indexPatterns$.pipe(delay(asyncDelay)).toPromise(); + }, + async get(indexPatternId: string) { + const indexPatterns$ = from( + indexPatterns.filter((indexPattern) => indexPattern.id === indexPatternId) + ); + return await indexPatterns$.pipe(delay(asyncDelay)).toPromise(); + }, + }; +}; diff --git a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.ts b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.ts new file mode 100644 index 0000000000000..a53cbcc170433 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState } from 'react'; +import { useTrackedPromise } from '../utils/use_tracked_promise'; +import { useKibanaContextForPlugin } from './use_kibana'; + +export const useKibanaIndexPatternService = () => { + const { + services: { + data: { indexPatterns }, + }, + } = useKibanaContextForPlugin(); + + return indexPatterns; +}; + +interface IndexPatternDescriptor { + id: string; + title: string; +} + +export const useKibanaIndexPatternTitles = () => { + const indexPatterns = useKibanaIndexPatternService(); + + const [indexPatternTitles, setIndexPatternTitles] = useState([]); + + const [indexPatternTitlesRequest, fetchIndexPatternTitles] = useTrackedPromise( + { + createPromise: () => indexPatterns.getIdsWithTitle(true), + onResolve: setIndexPatternTitles, + }, + [indexPatterns] + ); + + return { + fetchIndexPatternTitles, + indexPatternTitles, + latestIndexPatternTitlesRequest: indexPatternTitlesRequest, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx index 68b5a133550b0..ab409d661fe0a 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx @@ -28,6 +28,7 @@ export const LogEntryCategoriesPageProviders: React.FunctionComponent = ({ child sourceId={sourceId} spaceId={space.id} timestampField={resolvedSourceConfiguration.timestampField} + runtimeMappings={resolvedSourceConfiguration.runtimeMappings} > {children} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx index cb52dfd713578..628e2fb74d830 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx @@ -31,12 +31,14 @@ export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) sourceId={sourceId} spaceId={space.id} timestampField={resolvedSourceConfiguration.timestampField ?? ''} + runtimeMappings={resolvedSourceConfiguration.runtimeMappings} > {children} diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index 648915ad4075c..d43fe198c5077 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -40,6 +40,7 @@ export const LogsPageContent: React.FunctionComponent = () => { initialize(); }); + // !! Need to be kept in sync with the searchDeepLinks in x-pack/plugins/infra/public/plugin.ts const streamTab = { app: 'logs', title: streamTabTitle, diff --git a/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx index 236817ce3890f..3f8922b1871c9 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx @@ -10,7 +10,6 @@ import { EuiCode, EuiDescribedFormGroup, EuiFieldText, - EuiForm, EuiFormRow, EuiLink, EuiSpacer, @@ -18,27 +17,29 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; -import { InputFieldProps } from '../../../components/source_configuration/input_fields'; +import React, { useMemo } from 'react'; +import { FormElement } from './form_elements'; +import { getFormRowProps, getStringInputFieldProps } from './form_field_props'; +import { FormValidationError } from './validation_errors'; interface FieldsConfigurationPanelProps { isLoading: boolean; - readOnly: boolean; - tiebreakerFieldProps: InputFieldProps; - timestampFieldProps: InputFieldProps; + isReadOnly: boolean; + tiebreakerFieldFormElement: FormElement; + timestampFieldFormElement: FormElement; } export const FieldsConfigurationPanel = ({ isLoading, - readOnly, - tiebreakerFieldProps, - timestampFieldProps, + isReadOnly, + tiebreakerFieldFormElement, + timestampFieldFormElement, }: FieldsConfigurationPanelProps) => { - const isTimestampValueDefault = timestampFieldProps.value === '@timestamp'; - const isTiebreakerValueDefault = tiebreakerFieldProps.value === '_doc'; + const isTimestampValueDefault = timestampFieldFormElement.value === '@timestamp'; + const isTiebreakerValueDefault = tiebreakerFieldFormElement.value === '_doc'; return ( - + <>

} - isInvalid={timestampFieldProps.isInvalid} label={ } + {...useMemo(() => getFormRowProps(timestampFieldFormElement), [ + timestampFieldFormElement, + ])} > getStringInputFieldProps(timestampFieldFormElement), [ + timestampFieldFormElement, + ])} /> @@ -146,7 +150,6 @@ export const FieldsConfigurationPanel = ({ } > } - isInvalid={tiebreakerFieldProps.isInvalid} label={ } + {...useMemo(() => getFormRowProps(tiebreakerFieldFormElement), [ + tiebreakerFieldFormElement, + ])} > getStringInputFieldProps(tiebreakerFieldFormElement), [ + tiebreakerFieldFormElement, + ])} /> - + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/form_elements.tsx b/x-pack/plugins/infra/public/pages/logs/settings/form_elements.tsx new file mode 100644 index 0000000000000..751d9762b937a --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/form_elements.tsx @@ -0,0 +1,243 @@ +/* + * 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. + */ + +import equal from 'fast-deep-equal'; +import { useCallback, useMemo, useState } from 'react'; +import { useAsync } from 'react-use'; +import { ObjectEntries } from '../../../../common/utility_types'; +import { ChildFormValidationError, GenericValidationError } from './validation_errors'; + +const unsetValue = Symbol('unset form value'); + +type ValueUpdater = (updater: (previousValue: Value) => Value) => void; + +export interface FormElement { + initialValue: Value; + isDirty: boolean; + resetValue: () => void; + updateValue: ValueUpdater; + validity: FormElementValidity; + value: Value; +} + +type FormElementMap = { + [formElementName in keyof FormValues]: FormElement; +}; + +export interface CompositeFormElement + extends FormElement { + childFormElements: FormElementMap; +} + +export type FormElementValidity = + | { validity: 'valid' } + | { validity: 'invalid'; reasons: InvalidReason[] } + | { validity: 'pending' }; + +export const useFormElement = ({ + initialValue, + validate, +}: { + initialValue: Value; + validate?: (value: Value) => Promise; +}): FormElement => { + const [changedValue, setChangedValue] = useState(unsetValue); + + const value = changedValue !== unsetValue ? changedValue : initialValue; + + const updateValue = useCallback>( + (updater) => + setChangedValue((previousValue) => + previousValue === unsetValue ? updater(initialValue) : updater(previousValue) + ), + [initialValue] + ); + + const resetValue = useCallback(() => setChangedValue(unsetValue), []); + + const isDirty = useMemo(() => !equal(value, initialValue), [value, initialValue]); + + const validity = useValidity(value, validate); + + return useMemo( + () => ({ + initialValue, + isDirty, + resetValue, + updateValue, + validity, + value, + }), + [initialValue, isDirty, resetValue, updateValue, validity, value] + ); +}; + +export const useCompositeFormElement = ({ + childFormElements, + validate, +}: { + childFormElements: FormElementMap; + validate?: (values: FormValues) => Promise; +}): CompositeFormElement => { + const childFormElementEntries = useMemo( + () => Object.entries(childFormElements) as ObjectEntries, + // eslint-disable-next-line react-hooks/exhaustive-deps + Object.entries(childFormElements).flat() + ); + + const value = useMemo( + () => + childFormElementEntries.reduce( + (accumulatedFormValues, [formElementName, formElement]) => ({ + ...accumulatedFormValues, + [formElementName]: formElement.value, + }), + {} as FormValues + ), + [childFormElementEntries] + ); + + const updateValue = useCallback( + (updater: (previousValues: FormValues) => FormValues) => { + const newValues = updater(value); + + childFormElementEntries.forEach(([formElementName, formElement]) => + formElement.updateValue(() => newValues[formElementName]) + ); + }, + [childFormElementEntries, value] + ); + + const isDirty = useMemo( + () => childFormElementEntries.some(([, formElement]) => formElement.isDirty), + [childFormElementEntries] + ); + + const formValidity = useValidity(value, validate); + const childFormElementsValidity = useMemo< + FormElementValidity + >(() => { + if ( + childFormElementEntries.some(([, formElement]) => formElement.validity.validity === 'invalid') + ) { + return { + validity: 'invalid', + reasons: [{ type: 'child' }], + }; + } else if ( + childFormElementEntries.some(([, formElement]) => formElement.validity.validity === 'pending') + ) { + return { + validity: 'pending', + }; + } else { + return { + validity: 'valid', + }; + } + }, [childFormElementEntries]); + + const validity = useMemo(() => getCombinedValidity(formValidity, childFormElementsValidity), [ + formValidity, + childFormElementsValidity, + ]); + + const resetValue = useCallback(() => { + childFormElementEntries.forEach(([, formElement]) => formElement.resetValue()); + }, [childFormElementEntries]); + + const initialValue = useMemo( + () => + childFormElementEntries.reduce( + (accumulatedFormValues, [formElementName, formElement]) => ({ + ...accumulatedFormValues, + [formElementName]: formElement.initialValue, + }), + {} as FormValues + ), + [childFormElementEntries] + ); + + return useMemo( + () => ({ + childFormElements, + initialValue, + isDirty, + resetValue, + updateValue, + validity, + value, + }), + [childFormElements, initialValue, isDirty, resetValue, updateValue, validity, value] + ); +}; + +const useValidity = ( + value: Value, + validate?: (value: Value) => Promise +) => { + const validationState = useAsync(() => validate?.(value) ?? Promise.resolve([]), [ + validate, + value, + ]); + + const validity = useMemo>(() => { + if (validationState.loading) { + return { validity: 'pending' as const }; + } else if (validationState.error != null) { + return { + validity: 'invalid' as const, + reasons: [ + { + type: 'generic' as const, + message: `${validationState.error}`, + }, + ], + }; + } else if (validationState.value && validationState.value.length > 0) { + return { + validity: 'invalid' as const, + reasons: validationState.value, + }; + } else { + return { + validity: 'valid' as const, + }; + } + }, [validationState.error, validationState.loading, validationState.value]); + + return validity; +}; + +export const getCombinedValidity = ( + first: FormElementValidity, + second: FormElementValidity +): FormElementValidity => { + if (first.validity === 'invalid' || second.validity === 'invalid') { + return { + validity: 'invalid', + reasons: [ + ...(first.validity === 'invalid' ? first.reasons : []), + ...(second.validity === 'invalid' ? second.reasons : []), + ], + }; + } else if (first.validity === 'pending' || second.validity === 'pending') { + return { + validity: 'pending', + }; + } else { + return { + validity: 'valid', + }; + } +}; + +export const isFormElementForType = ( + isValue: (value: any) => value is Value +) => ( + formElement: FormElement +): formElement is FormElement => isValue(formElement.value); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/form_field_props.tsx b/x-pack/plugins/infra/public/pages/logs/settings/form_field_props.tsx new file mode 100644 index 0000000000000..4a3927157b136 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/form_field_props.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { FormElement } from './form_elements'; +import { LogSourceConfigurationFormError } from './source_configuration_form_errors'; +import { FormValidationError } from './validation_errors'; + +export const getFormRowProps = (formElement: FormElement) => ({ + error: + formElement.validity.validity === 'invalid' + ? formElement.validity.reasons.map((error) => ( + + )) + : [], + isInvalid: formElement.validity.validity === 'invalid', +}); + +export const getInputFieldProps = ( + decodeInputValue: (value: string) => Value, + encodeInputValue: (value: Value) => string +) => (formElement: FormElement) => ({ + isInvalid: formElement.validity.validity === 'invalid', + onChange: (evt: React.ChangeEvent) => { + const newValue = evt.currentTarget.value; + formElement.updateValue(() => decodeInputValue(newValue)); + }, + value: encodeInputValue(formElement.value), +}); + +export const getStringInputFieldProps = getInputFieldProps( + (value) => `${value}`, + (value) => value +); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx new file mode 100644 index 0000000000000..2d2909f42bae6 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiCallOut, + EuiCode, + EuiDescribedFormGroup, + EuiFieldText, + EuiFormRow, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { useTrackPageview } from '../../../../../observability/public'; +import { LogIndexNameReference } from '../../../../common/log_sources'; +import { FormElement } from './form_elements'; +import { getFormRowProps, getInputFieldProps } from './form_field_props'; +import { FormValidationError } from './validation_errors'; + +export const IndexNamesConfigurationPanel: React.FC<{ + isLoading: boolean; + isReadOnly: boolean; + indexNamesFormElement: FormElement; + onSwitchToIndexPatternReference: () => void; +}> = ({ isLoading, isReadOnly, indexNamesFormElement, onSwitchToIndexPatternReference }) => { + useTrackPageview({ app: 'infra_logs', path: 'log_source_configuration_index_name' }); + useTrackPageview({ + app: 'infra_logs', + path: 'log_source_configuration_index_name', + delay: 15000, + }); + + return ( + <> + +

+ +

+
+ + + + + + + + + + +

+ } + description={ + + } + > + logs-*,filebeat-*, + }} + /> + } + label={ + + } + {...getFormRowProps(indexNamesFormElement)} + > + + + + + ); +}; + +const getIndexNamesInputFieldProps = getInputFieldProps( + (value) => ({ + type: 'index_name', + indexName: value, + }), + ({ indexName }) => indexName +); + +const deprecationCalloutTitle = i18n.translate( + 'xpack.infra.logSourceConfiguration.indexNameReferenceDeprecationTitle', + { + defaultMessage: 'Deprecated configuration option', + } +); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx new file mode 100644 index 0000000000000..a16f15505bc30 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx @@ -0,0 +1,121 @@ +/* + * 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. + */ + +import { EuiDescribedFormGroup, EuiFormRow, EuiLink, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useCallback, useMemo } from 'react'; +import { useTrackPageview } from '../../../../../observability/public'; +import { LogIndexPatternReference } from '../../../../common/log_sources'; +import { useLinkProps } from '../../../hooks/use_link_props'; +import { FormElement } from './form_elements'; +import { getFormRowProps } from './form_field_props'; +import { IndexPatternSelector } from './index_pattern_selector'; +import { FormValidationError } from './validation_errors'; + +export const IndexPatternConfigurationPanel: React.FC<{ + isLoading: boolean; + isReadOnly: boolean; + indexPatternFormElement: FormElement; +}> = ({ isLoading, isReadOnly, indexPatternFormElement }) => { + useTrackPageview({ app: 'infra_logs', path: 'log_source_configuration_index_pattern' }); + useTrackPageview({ + app: 'infra_logs', + path: 'log_source_configuration_index_pattern', + delay: 15000, + }); + + const changeIndexPatternId = useCallback( + (indexPatternId: string | undefined) => { + if (indexPatternId != null) { + indexPatternFormElement.updateValue(() => ({ + type: 'index_pattern', + indexPatternId, + })); + } else { + indexPatternFormElement.updateValue(() => undefined); + } + }, + [indexPatternFormElement] + ); + + return ( + <> + +

+ +

+
+ + + + + +

+ } + description={ + + } + > + + } + {...useMemo(() => (isLoading ? {} : getFormRowProps(indexPatternFormElement)), [ + isLoading, + indexPatternFormElement, + ])} + > + + + + + ); +}; + +const IndexPatternInlineHelpMessage = React.memo(() => { + const indexPatternManagementLinkProps = useLinkProps({ + app: 'management', + pathname: '/kibana/indexPatterns', + }); + + return ( + + + + ), + }} + /> + ); +}); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx new file mode 100644 index 0000000000000..9e110db53a27f --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useKibanaIndexPatternTitles } from '../../../hooks/use_kibana_index_patterns'; + +type IndexPatternOption = EuiComboBoxOptionOption; + +export const IndexPatternSelector: React.FC<{ + indexPatternId: string | undefined; + isLoading: boolean; + isReadOnly: boolean; + onChangeIndexPatternId: (indexPatternId: string | undefined) => void; +}> = ({ indexPatternId, isLoading, isReadOnly, onChangeIndexPatternId }) => { + const { + indexPatternTitles: availableIndexPatterns, + latestIndexPatternTitlesRequest, + fetchIndexPatternTitles, + } = useKibanaIndexPatternTitles(); + + useEffect(() => { + fetchIndexPatternTitles(); + }, [fetchIndexPatternTitles]); + + const availableOptions = useMemo( + () => + availableIndexPatterns.map(({ id, title }) => ({ + key: id, + label: title, + value: id, + })), + [availableIndexPatterns] + ); + + const selectedOptions = useMemo( + () => availableOptions.filter(({ key }) => key === indexPatternId), + [availableOptions, indexPatternId] + ); + + const changeSelectedIndexPatterns = useCallback( + ([newlySelectedOption]: IndexPatternOption[]) => { + if (typeof newlySelectedOption?.key === 'string') { + return onChangeIndexPatternId(newlySelectedOption.key); + } + + return onChangeIndexPatternId(undefined); + }, + [onChangeIndexPatternId] + ); + + return ( + + isLoading={isLoading || latestIndexPatternTitlesRequest.state === 'pending'} + isDisabled={isReadOnly} + options={availableOptions} + placeholder={indexPatternSelectorPlaceholder} + selectedOptions={selectedOptions} + singleSelection={true} + onChange={changeSelectedIndexPatterns} + /> + ); +}; + +const indexPatternSelectorPlaceholder = i18n.translate( + 'xpack.infra.logSourceConfiguration.indexPatternSelectorPlaceholder', + { defaultMessage: 'Choose an index pattern' } +); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_form_state.ts b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_form_state.ts index b7656e6499006..49d14e04ca328 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_form_state.ts +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_form_state.ts @@ -5,120 +5,107 @@ * 2.0. */ -import { ReactNode, useCallback, useMemo, useState } from 'react'; +import { useMemo } from 'react'; +import { useUiTracker } from '../../../../../observability/public'; import { - createInputFieldProps, - validateInputFieldNotEmpty, -} from '../../../components/source_configuration/input_fields'; + LogIndexNameReference, + logIndexNameReferenceRT, + LogIndexPatternReference, +} from '../../../../common/log_sources'; +import { useKibanaIndexPatternService } from '../../../hooks/use_kibana_index_patterns'; +import { useCompositeFormElement, useFormElement } from './form_elements'; +import { + FormValidationError, + validateIndexPattern, + validateStringNotEmpty, +} from './validation_errors'; -interface FormState { - name: string; - description: string; - logAlias: string; - tiebreakerField: string; - timestampField: string; -} +export type LogIndicesFormState = LogIndexNameReference | LogIndexPatternReference | undefined; -type FormStateChanges = Partial; +export const useLogIndicesFormElement = (initialValue: LogIndicesFormState) => { + const indexPatternService = useKibanaIndexPatternService(); -export const useLogIndicesConfigurationFormState = ({ - initialFormState = defaultFormState, -}: { - initialFormState?: FormState; -}) => { - const [formStateChanges, setFormStateChanges] = useState({}); + const trackIndexPatternValidationError = useUiTracker({ app: 'infra_logs' }); - const resetForm = useCallback(() => setFormStateChanges({}), []); + const logIndicesFormElement = useFormElement({ + initialValue, + validate: useMemo( + () => async (logIndices) => { + if (logIndices == null) { + return validateStringNotEmpty('log index pattern', ''); + } else if (logIndexNameReferenceRT.is(logIndices)) { + return validateStringNotEmpty('log indices', logIndices.indexName); + } else { + const emptyStringErrors = validateStringNotEmpty( + 'log index pattern', + logIndices.indexPatternId + ); - const formState = useMemo( - () => ({ - ...initialFormState, - ...formStateChanges, - }), - [initialFormState, formStateChanges] - ); + if (emptyStringErrors.length > 0) { + return emptyStringErrors; + } - const nameFieldProps = useMemo( - () => - createInputFieldProps({ - errors: validateInputFieldNotEmpty(formState.name), - name: 'name', - onChange: (name) => setFormStateChanges((changes) => ({ ...changes, name })), - value: formState.name, - }), - [formState.name] - ); - const logAliasFieldProps = useMemo( - () => - createInputFieldProps({ - errors: validateInputFieldNotEmpty(formState.logAlias), - name: 'logAlias', - onChange: (logAlias) => setFormStateChanges((changes) => ({ ...changes, logAlias })), - value: formState.logAlias, - }), - [formState.logAlias] - ); - const tiebreakerFieldFieldProps = useMemo( - () => - createInputFieldProps({ - errors: validateInputFieldNotEmpty(formState.tiebreakerField), - name: `tiebreakerField`, - onChange: (tiebreakerField) => - setFormStateChanges((changes) => ({ ...changes, tiebreakerField })), - value: formState.tiebreakerField, - }), - [formState.tiebreakerField] - ); - const timestampFieldFieldProps = useMemo( - () => - createInputFieldProps({ - errors: validateInputFieldNotEmpty(formState.timestampField), - name: `timestampField`, - onChange: (timestampField) => - setFormStateChanges((changes) => ({ ...changes, timestampField })), - value: formState.timestampField, - }), - [formState.timestampField] - ); + const indexPatternErrors = validateIndexPattern( + await indexPatternService.get(logIndices.indexPatternId) + ); - const fieldProps = useMemo( - () => ({ - name: nameFieldProps, - logAlias: logAliasFieldProps, - tiebreakerField: tiebreakerFieldFieldProps, - timestampField: timestampFieldFieldProps, - }), - [nameFieldProps, logAliasFieldProps, tiebreakerFieldFieldProps, timestampFieldFieldProps] - ); + if (indexPatternErrors.length > 0) { + trackIndexPatternValidationError({ + metric: 'configuration_index_pattern_validation_failed', + }); + } else { + trackIndexPatternValidationError({ + metric: 'configuration_index_pattern_validation_succeeded', + }); + } - const errors = useMemo( - () => - Object.values(fieldProps).reduce( - (accumulatedErrors, { error }) => [...accumulatedErrors, ...error], - [] - ), - [fieldProps] - ); + return indexPatternErrors; + } + }, + [indexPatternService, trackIndexPatternValidationError] + ), + }); + + return logIndicesFormElement; +}; - const isFormValid = useMemo(() => errors.length <= 0, [errors]); +export interface FieldsFormState { + tiebreakerField: string; + timestampField: string; +} - const isFormDirty = useMemo(() => Object.keys(formStateChanges).length > 0, [formStateChanges]); +export const useFieldsFormElement = (initialValues: FieldsFormState) => { + const tiebreakerFieldFormElement = useFormElement({ + initialValue: initialValues.tiebreakerField, + validate: useMemo( + () => async (tiebreakerField) => validateStringNotEmpty('tiebreaker', tiebreakerField), + [] + ), + }); + + const timestampFieldFormElement = useFormElement({ + initialValue: initialValues.timestampField, + validate: useMemo( + () => async (timestampField) => validateStringNotEmpty('timestamp', timestampField), + [] + ), + }); + + const fieldsFormElement = useCompositeFormElement( + useMemo( + () => ({ + childFormElements: { + tiebreaker: tiebreakerFieldFormElement, + timestamp: timestampFieldFormElement, + }, + }), + [tiebreakerFieldFormElement, timestampFieldFormElement] + ) + ); return { - errors, - fieldProps, - formState, - formStateChanges, - isFormDirty, - isFormValid, - resetForm, + fieldsFormElement, + tiebreakerFieldFormElement, + timestampFieldFormElement, }; }; - -const defaultFormState: FormState = { - name: '', - description: '', - logAlias: '', - tiebreakerField: '', - timestampField: '', -}; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.stories.tsx b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.stories.tsx new file mode 100644 index 0000000000000..8cc9f5b4357ef --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.stories.tsx @@ -0,0 +1,168 @@ +/* + * 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. + */ + +import { EuiCodeBlock, EuiPage, EuiPageBody, EuiPageContent, PropsOf } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; +import { + MockIndexPatternsKibanaContextProvider, + MockIndexPatternSpec, +} from '../../../hooks/use_kibana_index_patterns.mock'; +import { + FieldsFormState, + LogIndicesFormState, + useFieldsFormElement, + useLogIndicesFormElement, +} from './indices_configuration_form_state'; +import { IndicesConfigurationPanel } from './indices_configuration_panel'; + +export default { + title: 'infra/logsSettings/indicesConfiguration', + decorators: [ + (WrappedStory, { args }) => { + return ( + + + + + + + + + + + + + + ); + }, + ], + argTypes: { + logIndices: { + control: { + type: 'object', + }, + }, + availableIndexPatterns: { + control: { + type: 'object', + }, + }, + }, +} as Meta; + +type IndicesConfigurationPanelProps = PropsOf; + +type IndicesConfigurationPanelStoryArgs = Pick< + IndicesConfigurationPanelProps, + 'isLoading' | 'isReadOnly' +> & { + availableIndexPatterns: MockIndexPatternSpec[]; + logIndices: LogIndicesFormState; + fields: FieldsFormState; +}; + +const IndicesConfigurationPanelTemplate: Story = ({ + isLoading, + isReadOnly, + logIndices, + fields, +}) => { + const logIndicesFormElement = useLogIndicesFormElement(logIndices); + const { tiebreakerFieldFormElement, timestampFieldFormElement } = useFieldsFormElement(fields); + + return ( + <> + + + // field states{'\n'} + {JSON.stringify( + { + logIndices: { + value: logIndicesFormElement.value, + validity: logIndicesFormElement.validity, + }, + tiebreakerField: { + value: tiebreakerFieldFormElement.value, + validity: tiebreakerFieldFormElement.validity, + }, + timestampField: { + value: timestampFieldFormElement.value, + validity: timestampFieldFormElement.validity, + }, + }, + null, + 2 + )} + + + ); +}; + +const defaultArgs: IndicesConfigurationPanelStoryArgs = { + isLoading: false, + isReadOnly: false, + logIndices: { + type: 'index_name' as const, + indexName: 'logs-*', + }, + fields: { + tiebreakerField: '_doc', + timestampField: '@timestamp', + }, + availableIndexPatterns: [ + { + id: 'INDEX_PATTERN_A', + title: 'pattern-a-*', + timeFieldName: '@timestamp', + fields: [ + { + name: '@timestamp', + type: KBN_FIELD_TYPES.DATE, + searchable: true, + aggregatable: true, + }, + { + name: 'message', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + ], + }, + { + id: 'INDEX_PATTERN_B', + title: 'pattern-b-*', + fields: [], + }, + ], +}; + +export const IndexNameWithDefaultFields = IndicesConfigurationPanelTemplate.bind({}); + +IndexNameWithDefaultFields.args = { + ...defaultArgs, +}; + +export const IndexPattern = IndicesConfigurationPanelTemplate.bind({}); + +IndexPattern.args = { + ...defaultArgs, + logIndices: undefined, +}; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx index e6f03e76255a2..6f762afd79244 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx @@ -5,85 +5,77 @@ * 2.0. */ +import React, { useCallback } from 'react'; +import { useUiTracker } from '../../../../../observability/public'; import { - EuiCode, - EuiDescribedFormGroup, - EuiFieldText, - EuiForm, - EuiFormRow, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; -import { InputFieldProps } from '../../../components/source_configuration/input_fields'; + logIndexNameReferenceRT, + LogIndexPatternReference, + logIndexPatternReferenceRT, + LogIndexReference, +} from '../../../../common/log_sources'; +import { FieldsConfigurationPanel } from './fields_configuration_panel'; +import { FormElement, isFormElementForType } from './form_elements'; +import { IndexNamesConfigurationPanel } from './index_names_configuration_panel'; +import { IndexPatternConfigurationPanel } from './index_pattern_configuration_panel'; +import { FormValidationError } from './validation_errors'; -interface IndicesConfigurationPanelProps { +export const IndicesConfigurationPanel = React.memo<{ isLoading: boolean; - readOnly: boolean; - logAliasFieldProps: InputFieldProps; -} + isReadOnly: boolean; + indicesFormElement: FormElement; + tiebreakerFieldFormElement: FormElement; + timestampFieldFormElement: FormElement; +}>( + ({ + isLoading, + isReadOnly, + indicesFormElement, + tiebreakerFieldFormElement, + timestampFieldFormElement, + }) => { + const trackSwitchToIndexPatternReference = useUiTracker({ app: 'infra_logs' }); -export const IndicesConfigurationPanel = ({ - isLoading, - readOnly, - logAliasFieldProps, -}: IndicesConfigurationPanelProps) => ( - - -

- -

-
- - - - - } - description={ - { + indicesFormElement.updateValue(() => undefined); + trackSwitchToIndexPatternReference({ + metric: 'configuration_switch_to_index_pattern_reference', + }); + }, [indicesFormElement, trackSwitchToIndexPatternReference]); + + if (isIndexPatternFormElement(indicesFormElement)) { + return ( + - } - > - logs-*,filebeat-*, - }} + ); + } else if (isIndexNamesFormElement(indicesFormElement)) { + return ( + <> + - } - isInvalid={logAliasFieldProps.isInvalid} - label={ - - } - > - - - -
+ + ); + } else { + return null; + } + } +); + +const isIndexPatternFormElement = isFormElementForType( + (value): value is LogIndexPatternReference | undefined => + value == null || logIndexPatternReferenceRT.is(value) ); + +const isIndexNamesFormElement = isFormElementForType(logIndexNameReferenceRT.is); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_form_state.tsx b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_form_state.tsx index 011fbf8a9d9a6..80eb44de9da9d 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_form_state.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_form_state.tsx @@ -5,150 +5,16 @@ * 2.0. */ -import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useCallback, useMemo, useState } from 'react'; -import { - FieldLogColumnConfiguration, - isMessageLogColumnConfiguration, - isTimestampLogColumnConfiguration, - LogColumnConfiguration, - MessageLogColumnConfiguration, - TimestampLogColumnConfiguration, -} from '../../../utils/source_configuration'; - -export interface TimestampLogColumnConfigurationProps { - logColumnConfiguration: TimestampLogColumnConfiguration['timestampColumn']; - remove: () => void; - type: 'timestamp'; -} - -export interface MessageLogColumnConfigurationProps { - logColumnConfiguration: MessageLogColumnConfiguration['messageColumn']; - remove: () => void; - type: 'message'; -} - -export interface FieldLogColumnConfigurationProps { - logColumnConfiguration: FieldLogColumnConfiguration['fieldColumn']; - remove: () => void; - type: 'field'; -} - -export type LogColumnConfigurationProps = - | TimestampLogColumnConfigurationProps - | MessageLogColumnConfigurationProps - | FieldLogColumnConfigurationProps; - -interface FormState { - logColumns: LogColumnConfiguration[]; -} - -type FormStateChanges = Partial; - -export const useLogColumnsConfigurationFormState = ({ - initialFormState = defaultFormState, -}: { - initialFormState?: FormState; -}) => { - const [formStateChanges, setFormStateChanges] = useState({}); - - const resetForm = useCallback(() => setFormStateChanges({}), []); - - const formState = useMemo( - () => ({ - ...initialFormState, - ...formStateChanges, - }), - [initialFormState, formStateChanges] - ); - - const logColumnConfigurationProps = useMemo( - () => - formState.logColumns.map( - (logColumn): LogColumnConfigurationProps => { - const remove = () => - setFormStateChanges((changes) => ({ - ...changes, - logColumns: formState.logColumns.filter((item) => item !== logColumn), - })); - - if (isTimestampLogColumnConfiguration(logColumn)) { - return { - logColumnConfiguration: logColumn.timestampColumn, - remove, - type: 'timestamp', - }; - } else if (isMessageLogColumnConfiguration(logColumn)) { - return { - logColumnConfiguration: logColumn.messageColumn, - remove, - type: 'message', - }; - } else { - return { - logColumnConfiguration: logColumn.fieldColumn, - remove, - type: 'field', - }; - } - } - ), - [formState.logColumns] - ); - - const addLogColumn = useCallback( - (logColumnConfiguration: LogColumnConfiguration) => - setFormStateChanges((changes) => ({ - ...changes, - logColumns: [...formState.logColumns, logColumnConfiguration], - })), - [formState.logColumns] - ); - - const moveLogColumn = useCallback( - (sourceIndex, destinationIndex) => { - if (destinationIndex >= 0 && sourceIndex <= formState.logColumns.length - 1) { - const newLogColumns = [...formState.logColumns]; - newLogColumns.splice(destinationIndex, 0, newLogColumns.splice(sourceIndex, 1)[0]); - setFormStateChanges((changes) => ({ - ...changes, - logColumns: newLogColumns, - })); - } - }, - [formState.logColumns] - ); - - const errors = useMemo( - () => - logColumnConfigurationProps.length <= 0 - ? [ - , - ] - : [], - [logColumnConfigurationProps] - ); - - const isFormValid = useMemo(() => (errors.length <= 0 ? true : false), [errors]); - - const isFormDirty = useMemo(() => Object.keys(formStateChanges).length > 0, [formStateChanges]); - - return { - addLogColumn, - moveLogColumn, - errors, - logColumnConfigurationProps, - formState, - formStateChanges, - isFormDirty, - isFormValid, - resetForm, - }; -}; - -const defaultFormState: FormState = { - logColumns: [], +import { useMemo } from 'react'; +import { LogColumnConfiguration } from '../../../utils/source_configuration'; +import { useFormElement } from './form_elements'; +import { FormValidationError, validateColumnListNotEmpty } from './validation_errors'; + +export const useLogColumnsFormElement = (initialValue: LogColumnConfiguration[]) => { + const logColumnsFormElement = useFormElement({ + initialValue, + validate: useMemo(() => async (logColumns) => validateColumnListNotEmpty(logColumns), []), + }); + + return logColumnsFormElement; }; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx index fb17f8bee3464..70db7837b8ae5 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx @@ -13,7 +13,6 @@ import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiForm, EuiIcon, EuiPanel, EuiSpacer, @@ -24,28 +23,54 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback } from 'react'; import { DragHandleProps, DropResult } from '../../../../../observability/public'; -import { LogColumnConfiguration } from '../../../utils/source_configuration'; -import { AddLogColumnButtonAndPopover } from './add_log_column_popover'; import { - FieldLogColumnConfigurationProps, - LogColumnConfigurationProps, -} from './log_columns_configuration_form_state'; + FieldLogColumnConfiguration, + getLogColumnConfigurationId, + isMessageLogColumnConfiguration, + isTimestampLogColumnConfiguration, + LogColumnConfiguration, + MessageLogColumnConfiguration, + TimestampLogColumnConfiguration, +} from '../../../utils/source_configuration'; +import { AddLogColumnButtonAndPopover } from './add_log_column_popover'; +import { FormElement } from './form_elements'; +import { LogSourceConfigurationFormError } from './source_configuration_form_errors'; +import { FormValidationError } from './validation_errors'; -interface LogColumnsConfigurationPanelProps { +export const LogColumnsConfigurationPanel = React.memo<{ availableFields: string[]; isLoading: boolean; - logColumnConfiguration: LogColumnConfigurationProps[]; - addLogColumn: (logColumn: LogColumnConfiguration) => void; - moveLogColumn: (sourceIndex: number, destinationIndex: number) => void; -} + logColumnsFormElement: FormElement; +}>(({ availableFields, isLoading, logColumnsFormElement }) => { + const addLogColumn = useCallback( + (logColumnConfiguration: LogColumnConfiguration) => + logColumnsFormElement.updateValue((logColumns) => [...logColumns, logColumnConfiguration]), + [logColumnsFormElement] + ); + + const removeLogColumn = useCallback( + (logColumn: LogColumnConfiguration) => + logColumnsFormElement.updateValue((logColumns) => + logColumns.filter((item) => item !== logColumn) + ), + [logColumnsFormElement] + ); + + const moveLogColumn = useCallback( + (sourceIndex, destinationIndex) => { + logColumnsFormElement.updateValue((logColumns) => { + if (destinationIndex >= 0 && sourceIndex <= logColumnsFormElement.value.length - 1) { + const newLogColumns = [...logColumnsFormElement.value]; + newLogColumns.splice(destinationIndex, 0, newLogColumns.splice(sourceIndex, 1)[0]); + return newLogColumns; + } else { + return logColumns; + } + }); + }, + [logColumnsFormElement] + ); -export const LogColumnsConfigurationPanel: React.FunctionComponent = ({ - addLogColumn, - moveLogColumn, - availableFields, - isLoading, - logColumnConfiguration, -}) => { const onDragEnd = useCallback( ({ source, destination }: DropResult) => destination && moveLogColumn(source.index, destination.index), @@ -53,7 +78,7 @@ export const LogColumnsConfigurationPanel: React.FunctionComponent + <> @@ -73,63 +98,89 @@ export const LogColumnsConfigurationPanel: React.FunctionComponent - {logColumnConfiguration.length > 0 ? ( + {logColumnsFormElement.value.length > 0 ? ( - <> - {/* Fragment here necessary for typechecking */} - {logColumnConfiguration.map((column, index) => ( + {logColumnsFormElement.value.map((logColumnConfiguration, index) => { + const columnId = getLogColumnConfigurationId(logColumnConfiguration); + return ( {(provided) => ( )} - ))} - + ); + })} ) : ( )} - + {logColumnsFormElement.validity.validity === 'invalid' + ? logColumnsFormElement.validity.reasons.map((error) => ( + + + + )) + : null} + + ); +}); + +const LogColumnConfigurationPanel: React.FunctionComponent<{ + logColumnConfiguration: LogColumnConfiguration; + dragHandleProps: DragHandleProps; + onRemove: (logColumnConfiguration: LogColumnConfiguration) => void; +}> = ({ logColumnConfiguration, dragHandleProps, onRemove }) => { + const removeColumn = useCallback(() => onRemove(logColumnConfiguration), [ + logColumnConfiguration, + onRemove, + ]); + + return ( + <> + + {isTimestampLogColumnConfiguration(logColumnConfiguration) ? ( + + ) : isMessageLogColumnConfiguration(logColumnConfiguration) ? ( + + ) : ( + + )} + ); }; -interface LogColumnConfigurationPanelProps { - logColumnConfigurationProps: LogColumnConfigurationProps; +interface LogColumnConfigurationPanelProps { + logColumnConfiguration: LogColumnConfigurationType; dragHandleProps: DragHandleProps; + onRemove: () => void; } -const LogColumnConfigurationPanel: React.FunctionComponent = ( - props -) => ( - <> - - {props.logColumnConfigurationProps.type === 'timestamp' ? ( - - ) : props.logColumnConfigurationProps.type === 'message' ? ( - - ) : ( - - )} - -); - -const TimestampLogColumnConfigurationPanel: React.FunctionComponent = ({ - logColumnConfigurationProps, - dragHandleProps, -}) => ( +const TimestampLogColumnConfigurationPanel: React.FunctionComponent< + LogColumnConfigurationPanelProps +> = ({ dragHandleProps, onRemove }) => ( } - removeColumn={logColumnConfigurationProps.remove} + onRemove={onRemove} dragHandleProps={dragHandleProps} /> ); -const MessageLogColumnConfigurationPanel: React.FunctionComponent = ({ - logColumnConfigurationProps, - dragHandleProps, -}) => ( +const MessageLogColumnConfigurationPanel: React.FunctionComponent< + LogColumnConfigurationPanelProps +> = ({ dragHandleProps, onRemove }) => ( } - removeColumn={logColumnConfigurationProps.remove} + onRemove={onRemove} dragHandleProps={dragHandleProps} /> ); -const FieldLogColumnConfigurationPanel: React.FunctionComponent<{ - logColumnConfigurationProps: FieldLogColumnConfigurationProps; - dragHandleProps: DragHandleProps; -}> = ({ - logColumnConfigurationProps: { - logColumnConfiguration: { field }, - remove, - }, +const FieldLogColumnConfigurationPanel: React.FunctionComponent< + LogColumnConfigurationPanelProps +> = ({ dragHandleProps, + logColumnConfiguration: { + fieldColumn: { field }, + }, + onRemove, }) => { - const fieldLogColumnTitle = i18n.translate( - 'xpack.infra.sourceConfiguration.fieldLogColumnTitle', - { - defaultMessage: 'Field', - } - ); return ( - +
@@ -195,7 +242,7 @@ const FieldLogColumnConfigurationPanel: React.FunctionComponent<{ @@ -207,11 +254,13 @@ const FieldLogColumnConfigurationPanel: React.FunctionComponent<{ const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{ fieldName: React.ReactNode; helpText: React.ReactNode; - removeColumn: () => void; + onRemove: () => void; dragHandleProps: DragHandleProps; -}> = ({ fieldName, helpText, removeColumn, dragHandleProps }) => ( +}> = ({ fieldName, helpText, onRemove, dragHandleProps }) => ( @@ -226,7 +275,7 @@ const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{ - + @@ -277,3 +326,7 @@ const LogColumnConfigurationEmptyPrompt: React.FunctionComponent = () => ( } /> ); + +const fieldLogColumnTitle = i18n.translate('xpack.infra.sourceConfiguration.fieldLogColumnTitle', { + defaultMessage: 'Field', +}); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/name_configuration_form_state.tsx b/x-pack/plugins/infra/public/pages/logs/settings/name_configuration_form_state.tsx new file mode 100644 index 0000000000000..f97ece074c4a3 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/name_configuration_form_state.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useFormElement } from './form_elements'; +import { FormValidationError, validateStringNotEmpty } from './validation_errors'; + +export const useNameFormElement = (initialValue: string) => { + const nameFormElement = useFormElement({ + initialValue, + validate: useMemo(() => async (name) => validateStringNotEmpty('name', name), []), + }); + + return nameFormElement; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/name_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/name_configuration_panel.tsx new file mode 100644 index 0000000000000..54158b654fee3 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/name_configuration_panel.tsx @@ -0,0 +1,69 @@ +/* + * 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. + */ + +import { + EuiDescribedFormGroup, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useMemo } from 'react'; +import { FormElement } from './form_elements'; +import { getFormRowProps, getStringInputFieldProps } from './form_field_props'; +import { FormValidationError } from './validation_errors'; + +export const NameConfigurationPanel = React.memo<{ + isLoading: boolean; + isReadOnly: boolean; + nameFormElement: FormElement; +}>(({ isLoading, isReadOnly, nameFormElement }) => ( + + +

+ +

+
+ + + + + } + description={ + + } + > + + } + {...useMemo(() => getFormRowProps(nameFormElement), [nameFormElement])} + > + getStringInputFieldProps(nameFormElement), [nameFormElement])} + /> + + +
+)); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_errors.tsx b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_errors.tsx new file mode 100644 index 0000000000000..af36a9dc0090b --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_errors.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCallOut, EuiCode } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { FormValidationError } from './validation_errors'; + +export const LogSourceConfigurationFormErrors: React.FC<{ errors: FormValidationError[] }> = ({ + errors, +}) => ( + +
    + {errors.map((error, errorIndex) => ( +
  • + +
  • + ))} +
+
+); + +export const LogSourceConfigurationFormError: React.FC<{ error: FormValidationError }> = ({ + error, +}) => { + if (error.type === 'generic') { + return <>{error.message}; + } else if (error.type === 'empty_field') { + return ( + + ); + } else if (error.type === 'empty_column_list') { + return ( + + ); + } else if (error.type === 'child') { + return ( + + ); + } else if (error.type === 'missing_timestamp_field') { + return ( + + ); + } else if (error.type === 'missing_message_field') { + return ( + message, + }} + /> + ); + } else if (error.type === 'invalid_message_field_type') { + return ( + message, + }} + /> + ); + } else if (error.type === 'rollup_index_pattern') { + return ( + + ); + } else { + return null; + } +}; + +const logSourceConfigurationFormErrorsCalloutTitle = i18n.translate( + 'xpack.infra.logSourceConfiguration.logSourceConfigurationFormErrorsCalloutTitle', + { + defaultMessage: 'Inconsistent source configuration', + } +); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_state.tsx b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_state.tsx index 95c55b556ab86..67e790a98f518 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_state.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_state.tsx @@ -5,103 +5,69 @@ * 2.0. */ -import { useCallback, useMemo } from 'react'; -import { ResolvedLogSourceConfiguration } from '../../../../common/log_sources'; -import { useLogIndicesConfigurationFormState } from './indices_configuration_form_state'; -import { useLogColumnsConfigurationFormState } from './log_columns_configuration_form_state'; +import { useMemo } from 'react'; +import { LogSourceConfigurationProperties } from '../../../containers/logs/log_source'; +import { useCompositeFormElement } from './form_elements'; +import { useFieldsFormElement, useLogIndicesFormElement } from './indices_configuration_form_state'; +import { useLogColumnsFormElement } from './log_columns_configuration_form_state'; +import { useNameFormElement } from './name_configuration_form_state'; export const useLogSourceConfigurationFormState = ( - configuration?: ResolvedLogSourceConfiguration + configuration?: LogSourceConfigurationProperties ) => { - const indicesConfigurationFormState = useLogIndicesConfigurationFormState({ - initialFormState: useMemo( - () => - configuration - ? { - name: configuration.name, - description: configuration.description, - logAlias: configuration.indices, - tiebreakerField: configuration.tiebreakerField, - timestampField: configuration.timestampField, - } - : undefined, - [configuration] - ), - }); + const nameFormElement = useNameFormElement(configuration?.name ?? ''); - const logColumnsConfigurationFormState = useLogColumnsConfigurationFormState({ - initialFormState: useMemo( + const logIndicesFormElement = useLogIndicesFormElement( + useMemo( () => - configuration - ? { - logColumns: configuration.columns, - } - : undefined, + configuration?.logIndices ?? { + type: 'index_name', + indexName: '', + }, [configuration] - ), - }); - - const errors = useMemo( - () => [...indicesConfigurationFormState.errors, ...logColumnsConfigurationFormState.errors], - [indicesConfigurationFormState.errors, logColumnsConfigurationFormState.errors] + ) ); - const resetForm = useCallback(() => { - indicesConfigurationFormState.resetForm(); - logColumnsConfigurationFormState.resetForm(); - }, [indicesConfigurationFormState, logColumnsConfigurationFormState]); - - const isFormDirty = useMemo( - () => indicesConfigurationFormState.isFormDirty || logColumnsConfigurationFormState.isFormDirty, - [indicesConfigurationFormState.isFormDirty, logColumnsConfigurationFormState.isFormDirty] - ); - - const isFormValid = useMemo( - () => indicesConfigurationFormState.isFormValid && logColumnsConfigurationFormState.isFormValid, - [indicesConfigurationFormState.isFormValid, logColumnsConfigurationFormState.isFormValid] + const { + fieldsFormElement, + tiebreakerFieldFormElement, + timestampFieldFormElement, + } = useFieldsFormElement( + useMemo( + () => ({ + tiebreakerField: configuration?.fields?.tiebreaker ?? '_doc', + timestampField: configuration?.fields?.timestamp ?? '@timestamp', + }), + [configuration] + ) ); - const formState = useMemo( - () => ({ - name: indicesConfigurationFormState.formState.name, - description: indicesConfigurationFormState.formState.description, - logAlias: indicesConfigurationFormState.formState.logAlias, - fields: { - tiebreaker: indicesConfigurationFormState.formState.tiebreakerField, - timestamp: indicesConfigurationFormState.formState.timestampField, - }, - logColumns: logColumnsConfigurationFormState.formState.logColumns, - }), - [indicesConfigurationFormState.formState, logColumnsConfigurationFormState.formState] + const logColumnsFormElement = useLogColumnsFormElement( + useMemo(() => configuration?.logColumns ?? [], [configuration]) ); - const formStateChanges = useMemo( - () => ({ - name: indicesConfigurationFormState.formStateChanges.name, - description: indicesConfigurationFormState.formStateChanges.description, - logAlias: indicesConfigurationFormState.formStateChanges.logAlias, - fields: { - tiebreaker: indicesConfigurationFormState.formStateChanges.tiebreakerField, - timestamp: indicesConfigurationFormState.formStateChanges.timestampField, - }, - logColumns: logColumnsConfigurationFormState.formStateChanges.logColumns, - }), - [ - indicesConfigurationFormState.formStateChanges, - logColumnsConfigurationFormState.formStateChanges, - ] + const sourceConfigurationFormElement = useCompositeFormElement( + useMemo( + () => ({ + childFormElements: { + name: nameFormElement, + logIndices: logIndicesFormElement, + fields: fieldsFormElement, + logColumns: logColumnsFormElement, + }, + validate: async () => [], + }), + [nameFormElement, logIndicesFormElement, fieldsFormElement, logColumnsFormElement] + ) ); return { - addLogColumn: logColumnsConfigurationFormState.addLogColumn, - moveLogColumn: logColumnsConfigurationFormState.moveLogColumn, - errors, - formState, - formStateChanges, - isFormDirty, - isFormValid, - indicesConfigurationProps: indicesConfigurationFormState.fieldProps, - logColumnConfigurationProps: logColumnsConfigurationFormState.logColumnConfigurationProps, - resetForm, + formState: sourceConfigurationFormElement.value, + logIndicesFormElement, + logColumnsFormElement, + nameFormElement, + sourceConfigurationFormElement, + tiebreakerFieldFormElement, + timestampFieldFormElement, }; }; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx index 2eaf4f61409a8..9ab7d38e6c838 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx @@ -7,33 +7,40 @@ import { EuiButton, - EuiCallOut, EuiErrorBoundary, EuiFlexGroup, EuiFlexItem, - EuiPanel, - EuiSpacer, EuiPage, EuiPageBody, + EuiPageContentBody, + EuiPanel, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useMemo } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { FieldsConfigurationPanel } from './fields_configuration_panel'; +import { useTrackPageview } from '../../../../../observability/public'; +import { SourceLoadingPage } from '../../../components/source_loading_page'; +import { useLogSourceContext } from '../../../containers/logs/log_source'; +import { Prompt } from '../../../utils/navigation_warning_prompt'; import { IndicesConfigurationPanel } from './indices_configuration_panel'; -import { NameConfigurationPanel } from '../../../components/source_configuration/name_configuration_panel'; import { LogColumnsConfigurationPanel } from './log_columns_configuration_panel'; +import { NameConfigurationPanel } from './name_configuration_panel'; +import { LogSourceConfigurationFormErrors } from './source_configuration_form_errors'; import { useLogSourceConfigurationFormState } from './source_configuration_form_state'; -import { useLogSourceContext } from '../../../containers/logs/log_source'; -import { SourceLoadingPage } from '../../../components/source_loading_page'; -import { Prompt } from '../../../utils/navigation_warning_prompt'; -import { LogSourceConfigurationPropertiesPatch } from '../../../../common/http_api/log_sources'; export const LogsSettingsPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; const shouldAllowEdit = uiCapabilities?.logs?.configureSource === true; + useTrackPageview({ app: 'infra_logs', path: 'log_source_configuration' }); + useTrackPageview({ + app: 'infra_logs', + path: 'log_source_configuration', + delay: 15000, + }); + const { sourceConfiguration: source, isLoading, @@ -48,35 +55,19 @@ export const LogsSettingsPage = () => { ); const { - addLogColumn, - moveLogColumn, - indicesConfigurationProps, - logColumnConfigurationProps, - errors, - resetForm, - isFormDirty, - isFormValid, - formStateChanges, - } = useLogSourceConfigurationFormState(resolvedSourceConfiguration); + sourceConfigurationFormElement, + formState, + logIndicesFormElement, + logColumnsFormElement, + nameFormElement, + tiebreakerFieldFormElement, + timestampFieldFormElement, + } = useLogSourceConfigurationFormState(source?.configuration); const persistUpdates = useCallback(async () => { - // NOTE / TODO: This is just a temporary workaround until this work is merged with the corresponding UI branch. - // Otherwise we would be duplicating work changing the logAlias etc references twice. - const patchedProperties: LogSourceConfigurationPropertiesPatch & { logAlias?: string } = { - ...formStateChanges, - ...(formStateChanges.logAlias - ? { - logIndices: { - type: 'index_name', - indexName: formStateChanges.logAlias, - }, - } - : {}), - }; - delete patchedProperties.logAlias; - await updateSourceConfiguration(patchedProperties); - resetForm(); - }, [updateSourceConfiguration, resetForm, formStateChanges]); + await updateSourceConfiguration(formState); + sourceConfigurationFormElement.resetValue(); + }, [updateSourceConfiguration, sourceConfigurationFormElement, formState]); const isWriteable = useMemo(() => shouldAllowEdit && source && source.origin !== 'internal', [ shouldAllowEdit, @@ -92,110 +83,100 @@ export const LogsSettingsPage = () => { return ( - - - - - - - - - - - - - + + + - - - - - - {errors.length > 0 ? ( - <> - -
    - {errors.map((error, errorIndex) => ( -
  • {error}
  • - ))} -
-
- - - ) : null} - - - {isWriteable && ( - - {isLoading ? ( - - - - Loading - - - - ) : ( - <> + + + + + + + + + + + + + {sourceConfigurationFormElement.validity.validity === 'invalid' ? ( + <> + + + + ) : null} + + {isWriteable && ( + + {isLoading ? ( - { - resetForm(); - }} - > - - - - - - + + Loading - - )} - - )} - + ) : ( + <> + + + { + sourceConfigurationFormElement.resetValue(); + }} + > + + + + + + + + + + + )} + + )} + +
diff --git a/x-pack/plugins/infra/public/pages/logs/settings/validation_errors.ts b/x-pack/plugins/infra/public/pages/logs/settings/validation_errors.ts new file mode 100644 index 0000000000000..b6e5a387590ed --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/validation_errors.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IndexPattern, KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; + +export interface GenericValidationError { + type: 'generic'; + message: string; +} + +export interface ChildFormValidationError { + type: 'child'; +} + +export interface EmptyFieldValidationError { + type: 'empty_field'; + fieldName: string; +} + +export interface EmptyColumnListValidationError { + type: 'empty_column_list'; +} + +export interface MissingTimestampFieldValidationError { + type: 'missing_timestamp_field'; + indexPatternTitle: string; +} + +export interface MissingMessageFieldValidationError { + type: 'missing_message_field'; + indexPatternTitle: string; +} + +export interface InvalidMessageFieldTypeValidationError { + type: 'invalid_message_field_type'; + indexPatternTitle: string; +} + +export interface RollupIndexPatternValidationError { + type: 'rollup_index_pattern'; + indexPatternTitle: string; +} + +export type FormValidationError = + | GenericValidationError + | ChildFormValidationError + | EmptyFieldValidationError + | EmptyColumnListValidationError + | MissingTimestampFieldValidationError + | MissingMessageFieldValidationError + | InvalidMessageFieldTypeValidationError + | RollupIndexPatternValidationError; + +export const validateStringNotEmpty = (fieldName: string, value: string): FormValidationError[] => + value === '' ? [{ type: 'empty_field', fieldName }] : []; + +export const validateColumnListNotEmpty = (columns: unknown[]): FormValidationError[] => + columns.length <= 0 ? [{ type: 'empty_column_list' }] : []; + +export const validateIndexPattern = (indexPattern: IndexPattern): FormValidationError[] => { + return [ + ...validateIndexPatternIsTimeBased(indexPattern), + ...validateIndexPatternHasStringMessageField(indexPattern), + ...validateIndexPatternIsntRollup(indexPattern), + ]; +}; + +export const validateIndexPatternIsTimeBased = ( + indexPattern: IndexPattern +): FormValidationError[] => + indexPattern.isTimeBased() + ? [] + : [ + { + type: 'missing_timestamp_field' as const, + indexPatternTitle: indexPattern.title, + }, + ]; + +export const validateIndexPatternHasStringMessageField = ( + indexPattern: IndexPattern +): FormValidationError[] => { + const messageField = indexPattern.getFieldByName('message'); + + if (messageField == null) { + return [ + { + type: 'missing_message_field' as const, + indexPatternTitle: indexPattern.title, + }, + ]; + } else if (messageField.type !== KBN_FIELD_TYPES.STRING) { + return [ + { + type: 'invalid_message_field_type' as const, + indexPatternTitle: indexPattern.title, + }, + ]; + } else { + return []; + } +}; + +export const validateIndexPatternIsntRollup = (indexPattern: IndexPattern): FormValidationError[] => + indexPattern.type != null + ? [ + { + type: 'rollup_index_pattern' as const, + indexPatternTitle: indexPattern.title, + }, + ] + : []; diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 51cc4ca098483..b43d7640f6390 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -120,6 +120,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { > + {/** !! Need to be kept in sync with the searchDeepLinks in x-pack/plugins/infra/public/plugin.ts */} { // mount callback should not use setup dependencies, get start dependencies instead @@ -82,6 +115,32 @@ export class Plugin implements InfraClientPluginClass { order: 8200, appRoute: '/app/metrics', category: DEFAULT_APP_CATEGORIES.observability, + meta: { + // !! Need to be kept in sync with the routes in x-pack/plugins/infra/public/pages/metrics/index.tsx + searchDeepLinks: [ + { + id: 'inventory', + title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', { + defaultMessage: 'Inventory', + }), + path: '/inventory', + }, + { + id: 'metrics-explorer', + title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', { + defaultMessage: 'Metrics Explorer', + }), + path: '/explorer', + }, + { + id: 'settings', + title: i18n.translate('xpack.infra.homePage.settingsTabTitle', { + defaultMessage: 'Settings', + }), + path: '/settings', + }, + ], + }, mount: async (params: AppMountParameters) => { // mount callback should not use setup dependencies, get start dependencies instead const [coreStart, pluginsStart] = await core.getStartServices(); diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts index 3d08d4fc270bc..8d51f54e3f55a 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts @@ -8,11 +8,12 @@ import { CoreStart } from 'kibana/public'; import { coreMock } from 'src/core/public/mocks'; import { dataPluginMock } from 'src/plugins/data/public/mocks'; -import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; +import { createIndexPatternMock } from '../../common/dependency_mocks/index_patterns'; +import { GetLogSourceConfigurationSuccessResponsePayload } from '../../common/http_api/log_sources/get_log_source_configuration'; import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; +import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; import { InfraClientStartDeps, InfraClientStartExports } from '../types'; import { getLogsHasDataFetcher, getLogsOverviewDataFetcher } from './logs_overview_fetchers'; -import { GetLogSourceConfigurationSuccessResponsePayload } from '../../common/http_api/log_sources/get_log_source_configuration'; jest.mock('../containers/logs/log_source/api/fetch_log_source_status'); const mockedCallFetchLogSourceStatusAPI = callFetchLogSourceStatusAPI as jest.MockedFunction< @@ -41,6 +42,36 @@ function setup() { // const dataResponder = jest.fn(); + (data.indexPatterns.get as jest.Mock).mockResolvedValue( + createIndexPatternMock({ + id: 'test-index-pattern', + title: 'log-indices-*', + timeFieldName: '@timestamp', + fields: [ + { + name: 'event.dataset', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + { + name: 'runtime_field', + type: 'string', + runtimeField: { + type: 'keyword', + script: { + source: 'emit("runtime value")', + }, + }, + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + ], + }) + ); + (data.search.search as jest.Mock).mockReturnValue({ subscribe: (progress: Function, error: Function, finish: Function) => { progress(dataResponder()); @@ -114,7 +145,7 @@ describe('Logs UI Observability Homepage Functions', () => { configuration: { logIndices: { type: 'index_pattern', - indexPatternId: 'some-test-id', + indexPatternId: 'test-index-pattern', }, fields: { timestamp: '@timestamp', tiebreaker: '_doc' }, }, diff --git a/x-pack/plugins/infra/public/utils/source_configuration.ts b/x-pack/plugins/infra/public/utils/source_configuration.ts index a3e1741c7590b..ac8a331e86952 100644 --- a/x-pack/plugins/infra/public/utils/source_configuration.ts +++ b/x-pack/plugins/infra/public/utils/source_configuration.ts @@ -31,3 +31,15 @@ export const isTimestampLogColumnConfiguration = ( logColumnConfiguration: LogColumnConfiguration ): logColumnConfiguration is TimestampLogColumnConfiguration => logColumnConfiguration != null && 'timestampColumn' in logColumnConfiguration; + +export const getLogColumnConfigurationId = ( + logColumnConfiguration: LogColumnConfiguration +): string => { + if (isTimestampLogColumnConfiguration(logColumnConfiguration)) { + return logColumnConfiguration.timestampColumn.id; + } else if (isMessageLogColumnConfiguration(logColumnConfiguration)) { + return logColumnConfiguration.messageColumn.id; + } else { + return logColumnConfiguration.fieldColumn.id; + } +}; diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index 33df2b4d55d22..3aaa747b945a8 100644 --- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -93,6 +93,7 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { ], }, }, + runtime_mappings: resolvedLogSourceConfiguration.runtimeMappings, sort, ...highlightClause, ...searchAfterClause, @@ -182,6 +183,7 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { ], }, }, + runtime_mappings: resolvedLogSourceConfiguration.runtimeMappings, size: 0, track_total_hits: false, }, diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts index 0914fab00dbe2..321273c656216 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts @@ -36,9 +36,7 @@ export async function getChartPreviewData( alertParams: GetLogAlertsChartPreviewDataAlertParamsSubset, buckets: number ) { - const indexPattern = resolvedLogSourceConfiguration.indices; - const timestampField = resolvedLogSourceConfiguration.timestampField; - + const { indices, timestampField, runtimeMappings } = resolvedLogSourceConfiguration; const { groupBy, timeSize, timeUnit } = alertParams; const isGrouped = groupBy && groupBy.length > 0 ? true : false; @@ -51,8 +49,8 @@ export async function getChartPreviewData( const { rangeFilter } = buildFiltersFromCriteria(expandedAlertParams, timestampField); const query = isGrouped - ? getGroupedESQuery(expandedAlertParams, timestampField, indexPattern) - : getUngroupedESQuery(expandedAlertParams, timestampField, indexPattern); + ? getGroupedESQuery(expandedAlertParams, timestampField, indices, runtimeMappings) + : getUngroupedESQuery(expandedAlertParams, timestampField, indices, runtimeMappings); if (!query) { throw new Error('ES query could not be built from the provided alert params'); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts index d2533fb4d79bc..1c1edb3ea8328 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts @@ -24,6 +24,7 @@ import { GroupedSearchQueryResponse, } from '../../../../common/alerting/logs/log_threshold/types'; import { alertsMock } from '../../../../../alerting/server/mocks'; +import { estypes } from '@elastic/elasticsearch'; // Mocks // const numericField = { @@ -69,6 +70,16 @@ const baseAlertParams: Pick = { const TIMESTAMP_FIELD = '@timestamp'; const FILEBEAT_INDEX = 'filebeat-*'; +const runtimeMappings: estypes.RuntimeFields = { + runtime_field: { + type: 'keyword', + script: { + lang: 'painless', + source: 'emit("a runtime value")', + }, + }, +}; + describe('Log threshold executor', () => { describe('Comparators', () => { test('Correctly categorises positive comparators', () => { @@ -188,11 +199,16 @@ describe('Log threshold executor', () => { ...baseAlertParams, criteria: [...positiveCriteria, ...negativeCriteria], }; - const query = getUngroupedESQuery(alertParams, TIMESTAMP_FIELD, FILEBEAT_INDEX); + const query = getUngroupedESQuery( + alertParams, + TIMESTAMP_FIELD, + FILEBEAT_INDEX, + runtimeMappings + ); expect(query).toEqual({ index: 'filebeat-*', - allowNoIndices: true, - ignoreUnavailable: true, + allow_no_indices: true, + ignore_unavailable: true, body: { track_total_hits: true, query: { @@ -274,6 +290,15 @@ describe('Log threshold executor', () => { ], }, }, + runtime_mappings: { + runtime_field: { + type: 'keyword', + script: { + lang: 'painless', + source: 'emit("a runtime value")', + }, + }, + }, size: 0, }, }); @@ -285,11 +310,16 @@ describe('Log threshold executor', () => { groupBy: ['host.name'], criteria: [...positiveCriteria, ...negativeCriteria], }; - const query = getGroupedESQuery(alertParams, TIMESTAMP_FIELD, FILEBEAT_INDEX); + const query = getGroupedESQuery( + alertParams, + TIMESTAMP_FIELD, + FILEBEAT_INDEX, + runtimeMappings + ); expect(query).toEqual({ index: 'filebeat-*', - allowNoIndices: true, - ignoreUnavailable: true, + allow_no_indices: true, + ignore_unavailable: true, body: { query: { bool: { @@ -405,6 +435,15 @@ describe('Log threshold executor', () => { }, }, }, + runtime_mappings: { + runtime_field: { + type: 'keyword', + script: { + lang: 'painless', + source: 'emit("a runtime value")', + }, + }, + }, size: 0, }, }); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index b81219b1afda2..3e910e5dfbf46 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { ElasticsearchClient } from 'kibana/server'; +import { estypes } from '@elastic/elasticsearch'; import { AlertExecutorOptions, AlertServices, @@ -73,15 +74,13 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => const { sources } = libs; const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default'); - const resolvedLogSourceConfiguration = await resolveLogSourceConfiguration( + const { indices, timestampField, runtimeMappings } = await resolveLogSourceConfiguration( sourceConfiguration.configuration, await libs.framework.getIndexPatternsService( savedObjectsClient, scopedClusterClient.asCurrentUser ) ); - const indexPattern = resolvedLogSourceConfiguration.indices; - const timestampField = resolvedLogSourceConfiguration.timestampField; try { const validatedParams = decodeOrThrow(alertParamsRT)(params); @@ -90,7 +89,8 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => await executeAlert( validatedParams, timestampField, - indexPattern, + indices, + runtimeMappings, scopedClusterClient.asCurrentUser, alertInstanceFactory ); @@ -98,7 +98,8 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => await executeRatioAlert( validatedParams, timestampField, - indexPattern, + indices, + runtimeMappings, scopedClusterClient.asCurrentUser, alertInstanceFactory ); @@ -112,10 +113,11 @@ async function executeAlert( alertParams: CountAlertParams, timestampField: string, indexPattern: string, + runtimeMappings: estypes.RuntimeFields, esClient: ElasticsearchClient, alertInstanceFactory: LogThresholdAlertServices['alertInstanceFactory'] ) { - const query = getESQuery(alertParams, timestampField, indexPattern); + const query = getESQuery(alertParams, timestampField, indexPattern, runtimeMappings); if (!query) { throw new Error('ES query could not be built from the provided alert params'); @@ -142,6 +144,7 @@ async function executeRatioAlert( alertParams: RatioAlertParams, timestampField: string, indexPattern: string, + runtimeMappings: estypes.RuntimeFields, esClient: ElasticsearchClient, alertInstanceFactory: LogThresholdAlertServices['alertInstanceFactory'] ) { @@ -156,8 +159,13 @@ async function executeRatioAlert( criteria: getDenominator(alertParams.criteria), }; - const numeratorQuery = getESQuery(numeratorParams, timestampField, indexPattern); - const denominatorQuery = getESQuery(denominatorParams, timestampField, indexPattern); + const numeratorQuery = getESQuery(numeratorParams, timestampField, indexPattern, runtimeMappings); + const denominatorQuery = getESQuery( + denominatorParams, + timestampField, + indexPattern, + runtimeMappings + ); if (!numeratorQuery || !denominatorQuery) { throw new Error('ES query could not be built from the provided ratio alert params'); @@ -189,11 +197,12 @@ async function executeRatioAlert( const getESQuery = ( alertParams: Omit & { criteria: CountCriteria }, timestampField: string, - indexPattern: string + indexPattern: string, + runtimeMappings: estypes.RuntimeFields ) => { return hasGroupBy(alertParams) - ? getGroupedESQuery(alertParams, timestampField, indexPattern) - : getUngroupedESQuery(alertParams, timestampField, indexPattern); + ? getGroupedESQuery(alertParams, timestampField, indexPattern, runtimeMappings) + : getUngroupedESQuery(alertParams, timestampField, indexPattern, runtimeMappings); }; export const processUngroupedResults = ( @@ -423,8 +432,9 @@ export const buildFiltersFromCriteria = ( export const getGroupedESQuery = ( params: Pick & { criteria: CountCriteria }, timestampField: string, - index: string -): object | undefined => { + index: string, + runtimeMappings: estypes.RuntimeFields +): estypes.SearchRequest | undefined => { const { groupBy } = params; if (!groupBy || !groupBy.length) { @@ -460,20 +470,21 @@ export const getGroupedESQuery = ( }, }; - const body = { + const body: estypes.SearchRequest['body'] = { query: { bool: { filter: [groupedRangeFilter], }, }, aggregations, + runtime_mappings: runtimeMappings, size: 0, }; return { index, - allowNoIndices: true, - ignoreUnavailable: true, + allow_no_indices: true, + ignore_unavailable: true, body, }; }; @@ -481,14 +492,15 @@ export const getGroupedESQuery = ( export const getUngroupedESQuery = ( params: Pick & { criteria: CountCriteria }, timestampField: string, - index: string + index: string, + runtimeMappings: estypes.RuntimeFields ): object => { const { rangeFilter, mustFilters, mustNotFilters } = buildFiltersFromCriteria( params, timestampField ); - const body = { + const body: estypes.SearchRequest['body'] = { // Ensure we accurately track the hit count for the ungrouped case, otherwise we can only ensure accuracy up to 10,000. track_total_hits: true, query: { @@ -497,13 +509,14 @@ export const getUngroupedESQuery = ( ...(mustNotFilters.length > 0 && { must_not: mustNotFilters }), }, }, + runtime_mappings: runtimeMappings, size: 0, }; return { index, - allowNoIndices: true, - ignoreUnavailable: true, + allow_no_indices: true, + ignore_unavailable: true, body, }; }; diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index ea57885bcdfbb..387143ef9f9c4 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/common'; import type { InfraPluginRequestHandlerContext } from '../../../types'; @@ -38,7 +39,6 @@ import { CompositeDatasetKey, createLogEntryDatasetsQuery, } from './queries/log_entry_datasets'; - export interface LogEntriesParams { startTimestamp: number; endTimestamp: number; @@ -276,7 +276,8 @@ export class InfraLogEntriesDomain { timestampField: string, indexName: string, startTime: number, - endTime: number + endTime: number, + runtimeMappings: estypes.RuntimeFields ) { let datasetBuckets: LogEntryDatasetBucket[] = []; let afterLatestBatchKey: CompositeDatasetKey | undefined; @@ -290,6 +291,7 @@ export class InfraLogEntriesDomain { timestampField, startTime, endTime, + runtimeMappings, COMPOSITE_AGGREGATION_BATCH_SIZE, afterLatestBatchKey ) diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/queries/log_entry_datasets.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/queries/log_entry_datasets.ts index 172c30780202c..18e04aaf063d4 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/queries/log_entry_datasets.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/queries/log_entry_datasets.ts @@ -6,6 +6,7 @@ */ import * as rt from 'io-ts'; +import { estypes } from '@elastic/elasticsearch'; import { commonSearchSuccessResponseFieldsRT } from '../../../../utils/elasticsearch_runtime_types'; @@ -14,6 +15,7 @@ export const createLogEntryDatasetsQuery = ( timestampField: string, startTime: number, endTime: number, + runtimeMappings: estypes.RuntimeFields, size: number, afterKey?: CompositeDatasetKey ) => ({ @@ -38,6 +40,7 @@ export const createLogEntryDatasetsQuery = ( ], }, }, + runtime_mappings: runtimeMappings, aggs: { dataset_buckets: { composite: { diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts index f5465a967f2a5..716ab400c0123 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import type { InfraPluginRequestHandlerContext, InfraRequestHandlerContext } from '../../types'; import { TracingSpan, startTracingSpan } from '../../../common/performance_tracing'; import { fetchMlJob, getLogEntryDatasets } from './common'; @@ -18,6 +19,7 @@ import { Pagination, isCategoryAnomaly, } from '../../../common/log_analysis'; +import type { ResolvedLogSourceConfiguration } from '../../../common/log_sources'; import type { MlSystem, MlAnomalyDetectors } from '../../types'; import { createLogEntryAnomaliesQuery, logEntryAnomaliesResponseRT } from './queries'; import { @@ -31,7 +33,6 @@ import { createLogEntryExamplesQuery, logEntryExamplesResponseRT, } from './queries/log_entry_examples'; -import { InfraSource } from '../sources'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; import { fetchLogEntryCategories } from './log_entry_categories_analysis'; @@ -326,7 +327,7 @@ export async function getLogEntryExamples( endTime: number, dataset: string, exampleCount: number, - sourceConfiguration: InfraSource, + resolvedSourceConfiguration: ResolvedLogSourceConfiguration, callWithRequest: KibanaFramework['callWithRequest'], categoryId?: string ) { @@ -346,7 +347,7 @@ export async function getLogEntryExamples( const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); const indices = customSettings?.logs_source_config?.indexPattern; const timestampField = customSettings?.logs_source_config?.timestampField; - const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker; + const { tiebreakerField, runtimeMappings } = resolvedSourceConfiguration; if (indices == null || timestampField == null) { throw new InsufficientLogAnalysisMlJobConfigurationError( @@ -361,6 +362,7 @@ export async function getLogEntryExamples( context, sourceId, indices, + runtimeMappings, timestampField, tiebreakerField, startTime, @@ -385,6 +387,7 @@ export async function fetchLogEntryExamples( context: InfraPluginRequestHandlerContext & { infra: Required }, sourceId: string, indices: string, + runtimeMappings: estypes.RuntimeFields, timestampField: string, tiebreakerField: string, startTime: number, @@ -431,6 +434,7 @@ export async function fetchLogEntryExamples( 'search', createLogEntryExamplesQuery( indices, + runtimeMappings, timestampField, tiebreakerField, startTime, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index 80061dac0a144..aea946ae87e74 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import type { ILegacyScopedClusterClient } from 'src/core/server'; import { compareDatasetsByMaximumAnomalyScore, @@ -14,6 +15,7 @@ import { CategoriesSort, } from '../../../common/log_analysis'; import { LogEntryContext } from '../../../common/log_entry'; +import type { ResolvedLogSourceConfiguration } from '../../../common/log_sources'; import { startTracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; @@ -36,7 +38,6 @@ import { createTopLogEntryCategoriesQuery, topLogEntryCategoriesResponseRT, } from './queries/top_log_entry_categories'; -import { InfraSource } from '../sources'; import { fetchMlJob, getLogEntryDatasets } from './common'; export async function getTopLogEntryCategories( @@ -147,7 +148,7 @@ export async function getLogEntryCategoryExamples( endTime: number, categoryId: number, exampleCount: number, - sourceConfiguration: InfraSource + resolvedSourceConfiguration: ResolvedLogSourceConfiguration ) { const finalizeLogEntryCategoryExamplesSpan = startTracingSpan('get category example log entries'); @@ -165,7 +166,7 @@ export async function getLogEntryCategoryExamples( const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); const indices = customSettings?.logs_source_config?.indexPattern; const timestampField = customSettings?.logs_source_config?.timestampField; - const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker; + const { tiebreakerField, runtimeMappings } = resolvedSourceConfiguration; if (indices == null || timestampField == null) { throw new InsufficientLogAnalysisMlJobConfigurationError( @@ -189,6 +190,7 @@ export async function getLogEntryCategoryExamples( } = await fetchLogEntryCategoryExamples( context, indices, + runtimeMappings, timestampField, tiebreakerField, startTime, @@ -402,6 +404,7 @@ async function fetchTopLogEntryCategoryHistograms( async function fetchLogEntryCategoryExamples( requestContext: { core: { elasticsearch: { legacy: { client: ILegacyScopedClusterClient } } } }, indices: string, + runtimeMappings: estypes.RuntimeFields, timestampField: string, tiebreakerField: string, startTime: number, @@ -418,6 +421,7 @@ async function fetchLogEntryCategoryExamples( 'search', createLogEntryCategoryExamplesQuery( indices, + runtimeMappings, timestampField, tiebreakerField, startTime, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts index cbaad4be7ee18..f06dcd43a9156 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts @@ -5,20 +5,21 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import * as rt from 'io-ts'; - import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; import { defaultRequestParameters } from './common'; export const createLogEntryCategoryExamplesQuery = ( indices: string, + runtimeMappings: estypes.RuntimeFields, timestampField: string, tiebreakerField: string, startTime: number, endTime: number, categoryQuery: string, exampleCount: number -) => ({ +): estypes.SearchRequest => ({ ...defaultRequestParameters, body: { query: { @@ -43,6 +44,7 @@ export const createLogEntryCategoryExamplesQuery = ( ], }, }, + runtime_mappings: runtimeMappings, sort: [{ [timestampField]: 'asc' }, { [tiebreakerField]: 'asc' }], _source: false, fields: ['event.dataset', 'message', 'container.id', 'host.name', 'log.file.path'], diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts index fca9c470f510f..1e8cbe247dd50 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts @@ -5,14 +5,15 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import * as rt from 'io-ts'; - +import { partitionField } from '../../../../common/log_analysis'; import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; import { defaultRequestParameters } from './common'; -import { partitionField } from '../../../../common/log_analysis'; export const createLogEntryExamplesQuery = ( indices: string, + runtimeMappings: estypes.RuntimeFields, timestampField: string, tiebreakerField: string, startTime: number, @@ -20,7 +21,7 @@ export const createLogEntryExamplesQuery = ( dataset: string, exampleCount: number, categoryQuery?: string -) => ({ +): estypes.SearchRequest => ({ ...defaultRequestParameters, body: { query: { @@ -61,7 +62,7 @@ export const createLogEntryExamplesQuery = ( match: { message: { query: categoryQuery, - operator: 'AND', + operator: 'AND' as const, }, }, }, @@ -70,6 +71,7 @@ export const createLogEntryExamplesQuery = ( ], }, }, + runtime_mappings: runtimeMappings, sort: [{ [timestampField]: 'asc' }, { [tiebreakerField]: 'asc' }], _source: false, fields: ['event.dataset', 'message'], diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts index d53ef3f3acdad..71558f97cf2bc 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts @@ -16,6 +16,7 @@ import type { InfraBackendLibs } from '../../../lib/infra_types'; import { getLogEntryCategoryExamples } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; import { isMlPrivilegesError } from '../../../lib/log_analysis/errors'; +import { resolveLogSourceConfiguration } from '../../../../common/log_sources'; export const initGetLogEntryCategoryExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { framework.registerRoute( @@ -40,6 +41,10 @@ export const initGetLogEntryCategoryExamplesRoute = ({ framework, sources }: Inf requestContext.core.savedObjects.client, sourceId ); + const resolvedSourceConfiguration = await resolveLogSourceConfiguration( + sourceConfiguration.configuration, + await framework.getIndexPatternsServiceWithRequestContext(requestContext) + ); try { assertHasInfraMlPlugins(requestContext); @@ -51,7 +56,7 @@ export const initGetLogEntryCategoryExamplesRoute = ({ framework, sources }: Inf endTime, categoryId, exampleCount, - sourceConfiguration + resolvedSourceConfiguration ); return response.ok({ diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts index f4d50f242686e..83e6934d1b7a4 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts @@ -16,6 +16,7 @@ import { LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, } from '../../../../common/http_api/log_analysis'; import { isMlPrivilegesError } from '../../../lib/log_analysis/errors'; +import { resolveLogSourceConfiguration } from '../../../../common/log_sources'; export const initGetLogEntryExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { framework.registerRoute( @@ -41,6 +42,10 @@ export const initGetLogEntryExamplesRoute = ({ framework, sources }: InfraBacken requestContext.core.savedObjects.client, sourceId ); + const resolvedSourceConfiguration = await resolveLogSourceConfiguration( + sourceConfiguration.configuration, + await framework.getIndexPatternsServiceWithRequestContext(requestContext) + ); try { assertHasInfraMlPlugins(requestContext); @@ -52,7 +57,7 @@ export const initGetLogEntryExamplesRoute = ({ framework, sources }: InfraBacken endTime, dataset, exampleCount, - sourceConfiguration, + resolvedSourceConfiguration, framework.callWithRequest, categoryId ); diff --git a/x-pack/plugins/infra/server/routes/log_analysis/validation/datasets.ts b/x-pack/plugins/infra/server/routes/log_analysis/validation/datasets.ts index 61a426ab40f0a..950ecc98619ee 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/validation/datasets.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/validation/datasets.ts @@ -6,6 +6,7 @@ */ import Boom from '@hapi/boom'; +import type { estypes } from '@elastic/elasticsearch'; import { InfraBackendLibs } from '../../../lib/infra_types'; import { @@ -31,7 +32,7 @@ export const initValidateLogAnalysisDatasetsRoute = ({ framework.router.handleLegacyErrors(async (requestContext, request, response) => { try { const { - data: { indices, timestampField, startTime, endTime }, + data: { indices, timestampField, startTime, endTime, runtimeMappings }, } = request.body; const datasets = await Promise.all( @@ -41,7 +42,8 @@ export const initValidateLogAnalysisDatasetsRoute = ({ timestampField, indexName, startTime, - endTime + endTime, + runtimeMappings as estypes.RuntimeFields ); return { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/validation/indices.ts b/x-pack/plugins/infra/server/routes/log_analysis/validation/indices.ts index 463ac77891263..4fd7096db06eb 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/validation/indices.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/validation/indices.ts @@ -36,7 +36,7 @@ export const initValidateLogAnalysisIndicesRoute = ({ framework }: InfraBackendL fold(throwErrors(Boom.badRequest), identity) ); - const { fields, indices } = payload.data; + const { fields, indices, runtimeMappings } = payload.data; const errors: ValidationIndicesError[] = []; // Query each pattern individually, to map correctly the errors @@ -47,6 +47,9 @@ export const initValidateLogAnalysisIndicesRoute = ({ framework }: InfraBackendL fields: fields.map((field) => field.name), ignore_unavailable: true, index, + body: { + runtime_mappings: runtimeMappings, + }, }); if (fieldCaps.indices.length === 0) { diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts index 7c7417d038e2e..3cda019359caf 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts @@ -19,13 +19,16 @@ import { SearchStrategyDependencies, } from 'src/plugins/data/server'; import { createSearchSessionsClientMock } from '../../../../../../src/plugins/data/server/search/mocks'; +import { + createIndexPatternMock, + createIndexPatternsStartMock, +} from '../../../common/dependency_mocks/index_patterns'; import { InfraSource } from '../../lib/sources'; import { createInfraSourcesMock } from '../../lib/sources/mocks'; import { logEntriesSearchRequestStateRT, logEntriesSearchStrategyProvider, } from './log_entries_search_strategy'; -import { getIndexPatternsMock } from './mocks'; describe('LogEntries search strategy', () => { it('handles initial search requests', async () => { @@ -72,6 +75,15 @@ describe('LogEntries search strategy', () => { index: 'log-indices-*', body: expect.objectContaining({ fields: expect.arrayContaining(['event.dataset', 'message']), + runtime_mappings: { + runtime_field: { + type: 'keyword', + script: { + lang: 'painless', + source: 'emit("runtime value")', + }, + }, + }, }), }), }), @@ -258,7 +270,7 @@ const createSourceConfigurationMock = (): InfraSource => ({ description: 'SOURCE_DESCRIPTION', logIndices: { type: 'index_pattern', - indexPatternId: 'some-test-id', + indexPatternId: 'test-index-pattern', }, metricAlias: 'metric-indices-*', inventoryDefaultView: 'DEFAULT_VIEW', @@ -323,5 +335,33 @@ const createDataPluginMock = (esSearchStrategyMock: ISearchStrategy): any => ({ search: { getSearchStrategy: jest.fn().mockReturnValue(esSearchStrategyMock), }, - indexPatterns: getIndexPatternsMock(), + indexPatterns: createIndexPatternsStartMock(0, [ + createIndexPatternMock({ + id: 'test-index-pattern', + title: 'log-indices-*', + timeFieldName: '@timestamp', + fields: [ + { + name: 'event.dataset', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + { + name: 'runtime_field', + type: 'string', + runtimeField: { + type: 'keyword', + script: { + source: 'emit("runtime value")', + }, + }, + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + ], + }), + ]), }); diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts index fc5dab9006df6..c47a1c163f9ec 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts @@ -109,7 +109,7 @@ export const logEntriesSearchStrategyProvider = ({ forkJoin([resolvedSourceConfiguration$, messageFormattingRules$]).pipe( map( ([ - { indices, timestampField, tiebreakerField, columns }, + { indices, timestampField, tiebreakerField, columns, runtimeMappings }, messageFormattingRules, ]): IEsSearchRequest => { return { @@ -123,6 +123,7 @@ export const logEntriesSearchStrategyProvider = ({ timestampField, tiebreakerField, getRequiredFields(params.columns ?? columns, messageFormattingRules), + runtimeMappings, params.query, params.highlightPhrase ), diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts index 785a4414a984c..f220c8913a2e6 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts @@ -18,14 +18,17 @@ import { ISearchStrategy, SearchStrategyDependencies, } from 'src/plugins/data/server'; -import { getIndexPatternsMock } from './mocks'; +import { createSearchSessionsClientMock } from '../../../../../../src/plugins/data/server/search/mocks'; +import { + createIndexPatternMock, + createIndexPatternsStartMock, +} from '../../../common/dependency_mocks/index_patterns'; +import { InfraSource } from '../../../common/source_configuration/source_configuration'; import { createInfraSourcesMock } from '../../lib/sources/mocks'; import { logEntrySearchRequestStateRT, logEntrySearchStrategyProvider, } from './log_entry_search_strategy'; -import { createSearchSessionsClientMock } from '../../../../../../src/plugins/data/server/search/mocks'; -import { InfraSource } from '../../../common/source_configuration/source_configuration'; describe('LogEntry search strategy', () => { it('handles initial search requests', async () => { @@ -61,7 +64,33 @@ describe('LogEntry search strategy', () => { .toPromise(); expect(sourcesMock.getSourceConfiguration).toHaveBeenCalled(); - expect(esSearchStrategyMock.search).toHaveBeenCalled(); + expect(esSearchStrategyMock.search).toHaveBeenCalledWith( + { + params: expect.objectContaining({ + index: 'log-indices-*', + body: expect.objectContaining({ + query: { + ids: { + values: ['LOG_ENTRY_ID'], + }, + }, + runtime_mappings: { + runtime_field: { + type: 'keyword', + script: { + lang: 'painless', + source: 'emit("runtime value")', + }, + }, + }, + }), + terminate_after: 1, + track_total_hits: false, + }), + }, + expect.anything(), + expect.anything() + ); expect(response.id).toEqual(expect.any(String)); expect(response.isRunning).toBe(true); }); @@ -207,7 +236,7 @@ const createSourceConfigurationMock = (): InfraSource => ({ description: 'SOURCE_DESCRIPTION', logIndices: { type: 'index_pattern', - indexPatternId: 'some-test-id', + indexPatternId: 'test-index-pattern', }, metricAlias: 'metric-indices-*', inventoryDefaultView: 'DEFAULT_VIEW', @@ -261,5 +290,33 @@ const createDataPluginMock = (esSearchStrategyMock: ISearchStrategy): any => ({ search: { getSearchStrategy: jest.fn().mockReturnValue(esSearchStrategyMock), }, - indexPatterns: getIndexPatternsMock(), + indexPatterns: createIndexPatternsStartMock(0, [ + createIndexPatternMock({ + id: 'test-index-pattern', + title: 'log-indices-*', + timeFieldName: '@timestamp', + fields: [ + { + name: 'event.dataset', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + { + name: 'runtime_field', + type: 'string', + runtimeField: { + type: 'keyword', + script: { + source: 'emit("runtime value")', + }, + }, + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + ], + }), + ]), }); diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts index c35c05d947da0..aa34204b9fb44 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts @@ -78,13 +78,19 @@ export const logEntrySearchStrategyProvider = ({ concatMap(({ params }) => resolvedSourceConfiguration$.pipe( map( - ({ indices, timestampField, tiebreakerField }): IEsSearchRequest => ({ + ({ + indices, + timestampField, + tiebreakerField, + runtimeMappings, + }): IEsSearchRequest => ({ // @ts-expect-error @elastic/elasticsearch declares indices_boost as Record params: createGetLogEntryQuery( indices, params.logEntryId, timestampField, - tiebreakerField + tiebreakerField, + runtimeMappings ), }) ) diff --git a/x-pack/plugins/infra/server/services/log_entries/mocks.ts b/x-pack/plugins/infra/server/services/log_entries/mocks.ts deleted file mode 100644 index 7c508b98554ec..0000000000000 --- a/x-pack/plugins/infra/server/services/log_entries/mocks.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IIndexPattern, IFieldType, IndexPatternsContract } from 'src/plugins/data/common'; - -const indexPatternFields: IFieldType[] = [ - { - name: 'event.dataset', - type: 'string', - esTypes: ['keyword'], - aggregatable: true, - filterable: true, - searchable: true, - }, -]; - -const indexPattern: IIndexPattern = { - id: '1234', - title: 'log-indices-*', - timeFieldName: '@timestamp', - fields: indexPatternFields, -}; - -export const getIndexPatternsMock = (): any => { - return { - indexPatternsServiceFactory: async () => { - return { - get: async (id) => indexPattern, - getFieldsForWildcard: async (options) => indexPatternFields, - } as Pick; - }, - }; -}; diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts index 6ae7232d77a17..6df17dbfd7bfd 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts @@ -29,6 +29,7 @@ export const createGetLogEntriesQuery = ( timestampField: string, tiebreakerField: string, fields: string[], + runtimeMappings?: estypes.RuntimeFields, query?: JsonObject, highlightTerm?: string ): estypes.AsyncSearchSubmitRequest => { @@ -53,6 +54,7 @@ export const createGetLogEntriesQuery = ( }, // @ts-expect-error @elastic/elasticsearch doesn't declare body.fields on AsyncSearchSubmitRequest fields, + runtime_mappings: runtimeMappings, _source: false, ...createSortClause(sortDirection, timestampField, tiebreakerField), ...createSearchAfterClause(cursor), diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts index 85af8b92fe080..6bef317d96ada 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts @@ -17,7 +17,8 @@ export const createGetLogEntryQuery = ( logEntryIndex: string, logEntryId: string, timestampField: string, - tiebreakerField: string + tiebreakerField: string, + runtimeMappings?: estypes.RuntimeFields ): estypes.AsyncSearchSubmitRequest => ({ index: logEntryIndex, terminate_after: 1, @@ -32,6 +33,7 @@ export const createGetLogEntryQuery = ( }, // @ts-expect-error @elastic/elasticsearch doesn't declare body.fields on AsyncSearchSubmitRequest fields: ['*'], + runtime_mappings: runtimeMappings, sort: [{ [timestampField]: 'desc' }, { [tiebreakerField]: 'desc' }], _source: false, }, diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index b7dbf1bbe4d87..87000865850e1 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { Observable } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { App } from './app'; @@ -77,6 +77,8 @@ function createMockFrame(): jest.Mocked { }; } +const sessionIdSubject = new Subject(); + function createMockSearchService() { let sessionIdCounter = 1; return { @@ -84,6 +86,7 @@ function createMockSearchService() { start: jest.fn(() => `sessionId-${sessionIdCounter++}`), clear: jest.fn(), getSessionId: jest.fn(() => `sessionId-${sessionIdCounter}`), + getSession$: jest.fn(() => sessionIdSubject.asObservable()), }, }; } @@ -1269,6 +1272,26 @@ describe('Lens App', () => { ); }); + it('updates the query if saved query is selected', () => { + const { component } = mountWith({}); + act(() => { + component.find(TopNavMenu).prop('onSavedQueryUpdated')!({ + id: '2', + attributes: { + title: 'new title', + description: '', + query: { query: 'abc:def', language: 'lucene' }, + }, + }); + }); + expect(TopNavMenu).toHaveBeenCalledWith( + expect.objectContaining({ + query: { query: 'abc:def', language: 'lucene' }, + }), + {} + ); + }); + it('clears all existing unpinned filters when the active saved query is cleared', () => { const { component, frame, services } = mountWith({}); act(() => @@ -1328,6 +1351,24 @@ describe('Lens App', () => { ); }); + it('re-renders the frame if session id changes from the outside', async () => { + const services = makeDefaultServices(); + const { frame } = mountWith({ props: undefined, services }); + + act(() => { + sessionIdSubject.next('new-session-id'); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + searchSessionId: `new-session-id`, + }) + ); + }); + it('updates the searchSessionId when the active saved query is cleared', () => { const { component, frame, services } = mountWith({}); act(() => diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 39163101fc7bd..f137047cfc871 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -14,7 +14,7 @@ import { Toast } from 'kibana/public'; import { VisualizeFieldContext } from 'src/plugins/ui_actions/public'; import { Datatable } from 'src/plugins/expressions/public'; import { EuiBreadcrumb } from '@elastic/eui'; -import { finalize, switchMap, tap } from 'rxjs/operators'; +import { delay, finalize, switchMap, tap } from 'rxjs/operators'; import { downloadMultipleAs } from '../../../../../src/plugins/share/public'; import { createKbnUrlStateStorage, @@ -82,6 +82,8 @@ export function App({ dashboardFeatureFlag, } = useKibana().services; + const startSession = useCallback(() => data.search.session.start(), [data]); + const [state, setState] = useState(() => { return { query: data.query.queryString.getQuery(), @@ -96,7 +98,7 @@ export function App({ isSaveModalVisible: false, indicateNoData: false, isSaveable: false, - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), }; }); @@ -178,7 +180,7 @@ export function App({ setState((s) => ({ ...s, filters: data.query.filterManager.getFilters(), - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), })); trackUiEvent('app_filters_updated'); }, @@ -188,7 +190,7 @@ export function App({ next: () => { setState((s) => ({ ...s, - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), })); }, }); @@ -199,7 +201,7 @@ export function App({ tap(() => { setState((s) => ({ ...s, - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), })); }), switchMap((done) => @@ -219,11 +221,29 @@ export function App({ kbnUrlStateStorage ); + const sessionSubscription = data.search.session + .getSession$() + // wait for a tick to filter/timerange subscribers the chance to update the session id in the state + .pipe(delay(0)) + // then update if it didn't get updated yet + .subscribe((newSessionId) => { + if (newSessionId) { + setState((prevState) => { + if (prevState.searchSessionId !== newSessionId) { + return { ...prevState, searchSessionId: newSessionId }; + } else { + return prevState; + } + }); + } + }); + return () => { stopSyncingQueryServiceStateWithUrl(); filterSubscription.unsubscribe(); timeSubscription.unsubscribe(); autoRefreshSubscription.unsubscribe(); + sessionSubscription.unsubscribe(); }; }, [ data.query.filterManager, @@ -234,6 +254,7 @@ export function App({ data.query, history, initialContext, + startSession, ]); useEffect(() => { @@ -652,7 +673,7 @@ export function App({ // Time change will be picked up by the time subscription setState((s) => ({ ...s, - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), })); trackUiEvent('app_query_change'); } @@ -671,6 +692,7 @@ export function App({ setState((s) => ({ ...s, savedQuery: { ...savedQuery }, // Shallow query for reference issues + query: savedQuery.attributes.query, })); }} onClearSavedQuery={() => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss index a58b5c21e7724..6629b44075831 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss @@ -1,7 +1,14 @@ +.lnsConfigPanel__addLayerBtnWrapper { + padding-bottom: $euiSize; +} + .lnsConfigPanel__addLayerBtn { - color: transparentize($euiColorMediumShade, .3); - // Remove EuiButton's default shadow to make button more subtle - // sass-lint:disable-block no-important - box-shadow: none !important; - border-color: $euiColorLightShade; + @include kbnThemeStyle('v7') { + // sass-lint:disable-block no-important + background-color: transparent !important; + color: transparentize($euiColorMediumShade, .3) !important; + border-color: $euiColorLightShade !important; + box-shadow: none !important; + } + } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 393c7363dc03f..d52fd29e7233a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -134,7 +134,7 @@ export function LayerPanels( ) : null )} {activeVisualization.appendLayer && visualizationState && ( - + { const id = generateId(); dispatch({ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss index b3e6f68b0a68c..b9f233d2b2950 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss @@ -27,6 +27,7 @@ margin-right: $euiSizeS; margin-left: $euiSizeXS / 2; margin-bottom: $euiSizeXS / 2; + box-shadow: none !important; // sass-lint:disable-line no-important &:focus { @include euiFocusRing; @@ -40,7 +41,7 @@ .lnsSuggestionPanel__button-isSelected { background-color: $euiColorLightestShade !important; // sass-lint:disable-line no-important - border-color: $euiColorMediumShade; + border-color: $euiColorMediumShade !important; // sass-lint:disable-line no-important &:not(:focus) { box-shadow: none !important; // sass-lint:disable-line no-important diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 9284b1dcc273d..e5acd2a2f47fd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -130,6 +130,8 @@ const SuggestionPreview = ({
{ expect(wrapper.find(NoFieldsCallout).length).toEqual(2); }); + it('should not allow field details when error', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(FieldList).prop('fieldGroups')).toEqual( + expect.objectContaining({ + AvailableFields: expect.objectContaining({ hideDetails: true }), + }) + ); + }); + + it('should allow field details when timeout', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(FieldList).prop('fieldGroups')).toEqual( + expect.objectContaining({ + AvailableFields: expect.objectContaining({ hideDetails: false }), + }) + ); + }); + it('should filter down by name', () => { const wrapper = mountWithIntl(); act(() => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 2cad77b003454..9fd389d4e65d3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -230,6 +230,7 @@ export function IndexPatternDataPanel({ onUpdateIndexPattern={onUpdateIndexPattern} existingFields={state.existingFields} existenceFetchFailed={state.existenceFetchFailed} + existenceFetchTimeout={state.existenceFetchTimeout} dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} /> @@ -271,6 +272,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ indexPatternRefs, indexPatterns, existenceFetchFailed, + existenceFetchTimeout, query, dateRange, filters, @@ -297,6 +299,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ charts: ChartsPluginSetup; indexPatternFieldEditor: IndexPatternFieldEditorStart; existenceFetchFailed?: boolean; + existenceFetchTimeout?: boolean; }) { const [localState, setLocalState] = useState({ nameFilter: '', @@ -314,7 +317,8 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ (type) => type in fieldTypeNames ); - const fieldInfoUnavailable = existenceFetchFailed || currentIndexPattern.hasRestrictions; + const fieldInfoUnavailable = + existenceFetchFailed || existenceFetchTimeout || currentIndexPattern.hasRestrictions; const editPermission = indexPatternFieldEditor.userPermissions.editIndexPattern(); @@ -389,7 +393,8 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ }), isAffectedByGlobalFilter: !!filters.length, isAffectedByTimeFilter: true, - hideDetails: fieldInfoUnavailable, + // Show details on timeout but not failure + hideDetails: fieldInfoUnavailable && !existenceFetchTimeout, defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noAvailableDataLabel', { defaultMessage: `There are no available fields that contain data.`, }), @@ -438,11 +443,12 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ return fieldGroupDefinitions; }, [ allFields, - existingFields, - currentIndexPattern, hasSyncedExistingFields, fieldInfoUnavailable, filters.length, + existenceFetchTimeout, + currentIndexPattern, + existingFields, ]); const fieldGroups: FieldGroups = useMemo(() => { @@ -503,6 +509,8 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ patterns: [currentIndexPattern.id], }); onUpdateIndexPattern(newlyMappedIndexPattern[currentIndexPattern.id]); + // start a new session so all charts are refreshed + data.search.session.start(); }, [data, currentIndexPattern, onUpdateIndexPattern]); const editField = useMemo( @@ -792,6 +800,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ filter={filter} currentIndexPatternId={currentIndexPatternId} existenceFetchFailed={existenceFetchFailed} + existenceFetchTimeout={existenceFetchTimeout} existFieldsInIndex={!!allFields.length} dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 1fc755ec489c7..2c503a7bd6967 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -542,7 +542,12 @@ export function DimensionEditor(props: DimensionEditorProps) { [columnId]: { ...selectedColumn, label: value, - customLabel: true, + customLabel: + operationDefinitionMap[selectedColumn.operationType].getDefaultLabel( + selectedColumn, + state.indexPatterns[state.layers[layerId].indexPatternId], + state.layers[layerId].columns + ) !== value, }, }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 7d1644d07d2aa..5e79fde0fa8fa 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -683,6 +683,44 @@ describe('IndexPatternDimensionEditorPanel', () => { ); }); + it('should remove customLabel flag if label is set to default', () => { + wrapper = mount( + + ); + + act(() => { + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'Maximum of bytes' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + label: 'Maximum of bytes', + customLabel: false, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + describe('transient invalid state', () => { it('should set the state if selecting an operation incompatible with the current field', () => { wrapper = mount(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx index ae7406e42746a..65bc23b4eb1ca 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx @@ -75,10 +75,10 @@ export function Filtering({ anchorClassName="eui-fullWidth" panelClassName="lnsIndexPatternDimensionEditor__filtersEditor" button={ - + {/* Empty for spacing */} - + + + { + updateLayer(setFilter(columnId, layer, undefined)); + }} + iconType="cross" + /> + } @@ -112,19 +125,6 @@ export function Filtering({ /> - - { - updateLayer(setFilter(columnId, layer, undefined)); - }} - iconType="cross" - /> - ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss index 19f5b91975202..a652a18752949 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss @@ -17,8 +17,14 @@ &:focus, &:focus-within, + .kbnFieldButton__button:focus:focus-visible, &.kbnFieldButton-isActive { - animation: none !important; // sass-lint:disable-line no-important + @include kbnThemeStyle('v7') { + animation: none !important; // sass-lint:disable-line no-important + } + @include kbnThemeStyle('v8') { + outline: none !important; // sass-lint:disable-line no-important + } } &:focus .kbnFieldButton__name span, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx index ceeb1f5b1caf3..ee0011ad0390c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx @@ -45,6 +45,7 @@ export const FieldList = React.memo(function FieldList({ exists, fieldGroups, existenceFetchFailed, + existenceFetchTimeout, fieldProps, hasSyncedExistingFields, filter, @@ -60,6 +61,7 @@ export const FieldList = React.memo(function FieldList({ fieldProps: FieldItemSharedProps; hasSyncedExistingFields: boolean; existenceFetchFailed?: boolean; + existenceFetchTimeout?: boolean; filter: { nameFilter: string; typeFilter: string[]; @@ -194,6 +196,7 @@ export const FieldList = React.memo(function FieldList({ ); }} showExistenceFetchError={existenceFetchFailed} + showExistenceFetchTimeout={existenceFetchTimeout} renderCallout={ boolean; showExistenceFetchError?: boolean; + showExistenceFetchTimeout?: boolean; hideDetails?: boolean; groupIndex: number; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; @@ -73,6 +74,7 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ exists, hideDetails, showExistenceFetchError, + showExistenceFetchTimeout, groupIndex, dropOntoWorkspace, hasSuggestionForField, @@ -133,25 +135,44 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ }, [label, helpTooltip]); const extraAction = useMemo(() => { - return showExistenceFetchError ? ( - - ) : hasLoaded ? ( - - {fieldsCount} - - ) : ( - - ); - }, [showExistenceFetchError, hasLoaded, isFiltered, fieldsCount]); + if (showExistenceFetchError) { + return ( + + ); + } + if (showExistenceFetchTimeout) { + return ( + + ); + } + if (hasLoaded) { + return ( + + {fieldsCount} + + ); + } + + return ; + }, [showExistenceFetchError, showExistenceFetchTimeout, hasLoaded, isFiltered, fieldsCount]); return ( { currentLayer: { ...initialState.layers.currentLayer, columns: { - cola: initialState.layers.currentLayer.columns.cola, + cola: { + dataType: 'string', + isBucketed: true, + sourceField: 'source', + label: 'values of source', + customLabel: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical', fallback: false }, + orderDirection: 'asc', + size: 5, + }, + }, }, columnOrder: ['cola'], }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index a68f8ae310f3e..d3913728cb64e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -17,6 +17,7 @@ import { injectReferences, } from './loader'; import { IndexPatternsContract } from '../../../../../src/plugins/data/public'; +import { HttpFetchError } from '../../../../../src/core/public'; import { IndexPatternPersistedState, IndexPatternPrivateState, @@ -877,6 +878,7 @@ describe('loader', () => { foo: 'bar', isFirstExistenceFetch: false, existenceFetchFailed: false, + existenceFetchTimeout: false, existingFields: { '1': { ip1_field_1: true, ip1_field_2: true }, '2': { ip2_field_1: true, ip2_field_2: true }, @@ -957,6 +959,56 @@ describe('loader', () => { }) as IndexPatternPrivateState; expect(newState.existenceFetchFailed).toEqual(true); + expect(newState.existenceFetchTimeout).toEqual(false); + expect(newState.existingFields['1']).toEqual({ + field1: true, + field2: true, + }); + }); + + it('should set all fields to available and existence error flag if the request times out', async () => { + const setState = jest.fn(); + const fetchJson = (jest.fn((path: string) => { + return new Promise((resolve, reject) => { + reject( + new HttpFetchError( + 'timeout', + 'name', + ({} as unknown) as Request, + ({ status: 408 } as unknown) as Response + ) + ); + }); + }) as unknown) as HttpHandler; + + const args = { + dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, + fetchJson, + indexPatterns: [ + { + id: '1', + title: '1', + hasRestrictions: false, + fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], + }, + ], + setState, + dslQuery, + showNoDataPopover: jest.fn(), + currentIndexPatternTitle: 'abc', + isFirstExistenceFetch: false, + }; + + await syncExistingFields(args); + + const [fn] = setState.mock.calls[0]; + const newState = fn({ + foo: 'bar', + existingFields: {}, + }) as IndexPatternPrivateState; + + expect(newState.existenceFetchFailed).toEqual(false); + expect(newState.existenceFetchTimeout).toEqual(true); expect(newState.existingFields['1']).toEqual({ field1: true, field2: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index ec7ef6a37a27a..0eb661e92bb1d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -445,16 +445,18 @@ export async function syncExistingFields({ ...state, isFirstExistenceFetch: false, existenceFetchFailed: false, + existenceFetchTimeout: false, existingFields: emptinessInfo.reduce((acc, info) => { acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); return acc; }, state.existingFields), })); } catch (e) { - // show all fields as available if fetch failed + // show all fields as available if fetch failed or timed out setState((state) => ({ ...state, - existenceFetchFailed: true, + existenceFetchFailed: e.res?.status !== 408, + existenceFetchTimeout: e.res?.status === 408, existingFields: indexPatterns.reduce((acc, pattern) => { acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); return acc; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 0b63dc6ece974..37bd64251ed81 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -179,7 +179,7 @@ interface BaseOperationDefinitionProps { columns: Record ) => string; /** - * This function is called if another column in the same layer changed or got removed. + * This function is called if another column in the same layer changed or got added/removed. * Can be used to update references to other columns (e.g. for sorting). * Based on the current column and the other updated columns, this function has to * return an updated column. If not implemented, the `id` function is used instead. diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx index 38666185eda0e..b2cfc0e5a7c2c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx @@ -51,7 +51,7 @@ const BucketContainer = ({ isNotRemovable, }: BucketContainerProps) => { return ( - + {/* Empty for spacing */} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 857e8b3605cfc..d226fe6f2a745 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -57,7 +57,9 @@ export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { operationType: 'terms'; params: { size: number; - orderBy: { type: 'alphabetical' } | { type: 'column'; columnId: string }; + // if order is alphabetical, the `fallback` flag indicates whether it became alphabetical because there wasn't + // another option or whether the user explicitly chose to make it alphabetical. + orderBy: { type: 'alphabetical'; fallback?: boolean } | { type: 'column'; columnId: string }; orderDirection: 'asc' | 'desc'; otherBucket?: boolean; missingBucket?: boolean; @@ -123,7 +125,7 @@ export const termsOperation: OperationDefinition { const columns = layer.columns; const currentColumn = columns[thisColumnId] as TermsIndexPatternColumn; - if (currentColumn.params.orderBy.type === 'column') { + if (currentColumn.params.orderBy.type === 'column' || currentColumn.params.orderBy.fallback) { // check whether the column is still there and still a metric - const columnSortedBy = columns[currentColumn.params.orderBy.columnId]; - if (!columnSortedBy || !isSortableByColumn(layer, changedColumnId)) { + const columnSortedBy = + currentColumn.params.orderBy.type === 'column' + ? columns[currentColumn.params.orderBy.columnId] + : undefined; + if ( + !columnSortedBy || + (currentColumn.params.orderBy.type === 'column' && + !isSortableByColumn(layer, currentColumn.params.orderBy.columnId)) + ) { + // check whether we can find another metric column to sort by + const existingMetricColumn = Object.entries(layer.columns) + .filter(([columnId]) => isSortableByColumn(layer, columnId)) + .map(([id]) => id)[0]; return { ...currentColumn, params: { ...currentColumn.params, - orderBy: { type: 'alphabetical' }, - orderDirection: 'asc', + orderBy: existingMetricColumn + ? { type: 'column', columnId: existingMetricColumn } + : { type: 'alphabetical', fallback: true }, + orderDirection: existingMetricColumn ? 'desc' : 'asc', }, }; } @@ -197,7 +212,7 @@ export const termsOperation: OperationDefinition) => + onChange={(e: React.ChangeEvent) => { + const newOrderByValue = fromValue(e.target.value); + const updatedLayer = updateColumnParam({ + layer, + columnId, + paramName: 'orderBy', + value: newOrderByValue, + }); updateLayer( updateColumnParam({ - layer, + layer: updatedLayer, columnId, - paramName: 'orderBy', - value: fromValue(e.target.value), + paramName: 'orderDirection', + value: newOrderByValue.type === 'alphabetical' ? 'asc' : 'desc', }) - ) - } + ); + }} aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', { defaultMessage: 'Rank by', })} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index f75bec141ccae..2e7307f6a2ec4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -358,7 +358,7 @@ describe('terms', () => { }, }); expect(termsColumn.params).toEqual( - expect.objectContaining({ orderBy: { type: 'alphabetical' } }) + expect.objectContaining({ orderBy: { type: 'alphabetical', fallback: true } }) ); }); @@ -469,7 +469,7 @@ describe('terms', () => { ); expect(updatedColumn.params).toEqual( expect.objectContaining({ - orderBy: { type: 'alphabetical' }, + orderBy: { type: 'alphabetical', fallback: true }, }) ); }); @@ -516,7 +516,7 @@ describe('terms', () => { ); expect(updatedColumn.params).toEqual( expect.objectContaining({ - orderBy: { type: 'alphabetical' }, + orderBy: { type: 'alphabetical', fallback: true }, }) ); }); @@ -548,7 +548,7 @@ describe('terms', () => { ); expect(termsColumn.params).toEqual( expect.objectContaining({ - orderBy: { type: 'alphabetical' }, + orderBy: { type: 'alphabetical', fallback: true }, }) ); }); @@ -592,7 +592,81 @@ describe('terms', () => { ); expect(termsColumn.params).toEqual( expect.objectContaining({ - orderBy: { type: 'alphabetical' }, + orderBy: { type: 'alphabetical', fallback: true }, + }) + ); + }); + + it('should set order to ascending if falling back to alphabetical', () => { + const termsColumn = termsOperation.onOtherColumnChanged!( + { + columns: { + col2: { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col1' }, + size: 3, + orderDirection: 'desc', + }, + sourceField: 'category', + }, + }, + columnOrder: [], + indexPatternId: '', + }, + 'col2', + 'col1' + ); + expect(termsColumn.params).toEqual( + expect.objectContaining({ + orderDirection: 'asc', + }) + ); + }); + + it('should switch back to descending metric sorting if alphabetical sorting was applied as fallback', () => { + const initialColumn: TermsIndexPatternColumn = { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical', fallback: true }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }; + const updatedColumn = termsOperation.onOtherColumnChanged!( + { + indexPatternId: '', + columnOrder: [], + columns: { + col2: initialColumn, + col1: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + 'col2', + 'col1' + ); + + expect(updatedColumn.params).toEqual( + expect.objectContaining({ + orderBy: { type: 'column', columnId: 'col1' }, + orderDirection: 'desc', }) ); }); @@ -774,6 +848,7 @@ describe('terms', () => { type: 'column', columnId: 'col2', }, + orderDirection: 'desc', }, }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 34e2eb2c90122..d3ca70c086cb5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -352,6 +352,50 @@ describe('state_helpers', () => { ).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2'] })); }); + it('should call onOtherColumn changed on existing columns', () => { + expect( + insertNewColumn({ + layer: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical', fallback: true }, + orderDirection: 'asc', + size: 5, + }, + }, + }, + }, + columnId: 'col2', + indexPattern, + op: 'sum', + field: indexPattern.fields[2], + visualizationGroups: [], + }) + ).toEqual( + expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + params: { + orderBy: { columnId: 'col2', type: 'column' }, + orderDirection: 'desc', + size: 5, + }, + }), + }), + }) + ); + }); + it('should allow multiple metrics', () => { expect( insertNewColumn({ @@ -908,7 +952,11 @@ describe('state_helpers', () => { columns: { col1: { ...termsColumn, - params: { orderBy: { type: 'alphabetical' }, orderDirection: 'asc', size: 5 }, + params: { + orderBy: { type: 'alphabetical', fallback: true }, + orderDirection: 'asc', + size: 5, + }, }, id1: expect.objectContaining({ dataType: 'number', @@ -1624,7 +1672,7 @@ describe('state_helpers', () => { ...termsColumn, params: { ...termsColumn.params, - orderBy: { type: 'alphabetical' }, + orderBy: { type: 'alphabetical', fallback: true }, orderDirection: 'asc', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 1661e5de8248e..bbe2ca4cd3d61 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -714,7 +714,11 @@ function addBucket( columns: { ...layer.columns, [addedColumnId]: column }, columnOrder: updatedColumnOrder, }; - return { ...tempLayer, columnOrder: getColumnOrder(tempLayer) }; + return { + ...tempLayer, + columns: adjustColumnReferencesForChangedColumn(tempLayer, addedColumnId), + columnOrder: getColumnOrder(tempLayer), + }; } export function reorderByGroups( @@ -766,7 +770,11 @@ function addMetric( [addedColumnId]: column, }, }; - return { ...tempLayer, columnOrder: getColumnOrder(tempLayer) }; + return { + ...tempLayer, + columnOrder: getColumnOrder(tempLayer), + columns: adjustColumnReferencesForChangedColumn(tempLayer, addedColumnId), + }; } export function getMetricOperationTypes(field: IndexPatternField) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 18f653c588ee8..98dc767c44c7d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -87,6 +87,7 @@ export interface IndexPatternPrivateState { existingFields: Record>; isFirstExistenceFetch: boolean; existenceFetchFailed?: boolean; + existenceFetchTimeout?: boolean; } export interface IndexPatternRef { diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index e18878ea064ef..4dcd9772b61b4 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -217,7 +217,16 @@ describe('PieVisualization component', () => { const component = shallow(); component.find(Settings).first().prop('onElementClick')!([ [ - [{ groupByRollup: 6, value: 6, depth: 1, path: [], sortIndex: 1 }], + [ + { + groupByRollup: 6, + value: 6, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ], {} as SeriesIdentifier, ], ]); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts index 6e40b07af6713..6ea8610384e47 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts @@ -66,7 +66,16 @@ describe('render helpers', () => { }; expect( getFilterContext( - [{ groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }], + [ + { + groupByRollup: 'Test', + value: 100, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ], ['a'], table ) @@ -98,7 +107,16 @@ describe('render helpers', () => { }; expect( getFilterContext( - [{ groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }], + [ + { + groupByRollup: 'Test', + value: 100, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ], ['a', 'b'], table ) @@ -131,8 +149,22 @@ describe('render helpers', () => { expect( getFilterContext( [ - { groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }, - { groupByRollup: 'Two', value: 5, depth: 1, path: [], sortIndex: 1 }, + { + groupByRollup: 'Test', + value: 100, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + { + groupByRollup: 'Two', + value: 5, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, ], ['a', 'b'], table diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 2487ddf32cd1f..e1dbd4da4b902 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -822,6 +822,59 @@ describe('xy_expression', () => { }); }); + test('returns correct original data for ordinal x axis with special formatter', () => { + const geometry: GeometryValue = { x: 'BAR', y: 1, accessor: 'y1', mark: null, datum: {} }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'a', + splitAccessors: {}, + seriesKeys: ['a'], + }; + + const { args, data } = sampleArgs(); + + convertSpy.mockImplementation((x) => (typeof x === 'string' ? x.toUpperCase() : x)); + + const wrapper = mountWithIntl( + + ); + + wrapper.find(Settings).first().prop('onElementClick')!([ + [geometry, series as XYChartSeriesIdentifier], + ]); + + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 3, + row: 1, + table: data.tables.first, + value: 'Bar', + }, + ], + }); + }); + test('onElementClick is not triggering event on noInteractivity mode', () => { const { args, data } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index a78d86b3f864c..47b8dbfc15f53 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -456,19 +456,27 @@ export function XYChart({ const table = data.tables[layer.layerId]; + const xColumn = table.columns.find((col) => col.id === layer.xAccessor); + const currentXFormatter = + layer.xAccessor && layersAlreadyFormatted[layer.xAccessor] && xColumn + ? formatFactory(xColumn.meta.params) + : xAxisFormatter; + + const rowIndex = table.rows.findIndex((row) => { + if (layer.xAccessor) { + if (layersAlreadyFormatted[layer.xAccessor]) { + // stringify the value to compare with the chart value + return currentXFormatter.convert(row[layer.xAccessor]) === xyGeometry.x; + } + return row[layer.xAccessor] === xyGeometry.x; + } + }); + const points = [ { - row: table.rows.findIndex((row) => { - if (layer.xAccessor) { - if (layersAlreadyFormatted[layer.xAccessor]) { - // stringify the value to compare with the chart value - return xAxisFormatter.convert(row[layer.xAccessor]) === xyGeometry.x; - } - return row[layer.xAccessor] === xyGeometry.x; - } - }), + row: rowIndex, column: table.columns.findIndex((col) => col.id === layer.xAccessor), - value: xyGeometry.x, + value: layer.xAccessor ? table.rows[rowIndex][layer.xAccessor] : xyGeometry.x, }, ]; @@ -626,7 +634,11 @@ export function XYChart({ const newRow = { ...row }; for (const column of table.columns) { const record = newRow[column.id]; - if (record && !isPrimitive(record)) { + if ( + record && + // pre-format values for ordinal x axes because there can only be a single x axis formatter on chart level + (!isPrimitive(record) || (column.id === xAccessor && xScaleType === 'ordinal')) + ) { newRow[column.id] = formatFactory(column.meta.params).convert(record); } } diff --git a/x-pack/plugins/lens/server/routes/existing_fields.ts b/x-pack/plugins/lens/server/routes/existing_fields.ts index 2e6d612835231..d775113d83ff7 100644 --- a/x-pack/plugins/lens/server/routes/existing_fields.ts +++ b/x-pack/plugins/lens/server/routes/existing_fields.ts @@ -68,8 +68,15 @@ export async function existingFieldsRoute(setup: CoreSetup, }), }); } catch (e) { + if (e instanceof errors.TimeoutError) { + logger.info(`Field existence check timed out on ${req.params.indexPatternId}`); + // 408 is Request Timeout + return res.customError({ statusCode: 408, body: e.message }); + } logger.info( - `Field existence check failed: ${isBoomError(e) ? e.output.payload.message : e.message}` + `Field existence check failed on ${req.params.indexPatternId}: ${ + isBoomError(e) ? e.output.payload.message : e.message + }` ); if (e instanceof errors.ResponseError && e.statusCode === 404) { return res.notFound({ body: e.message }); @@ -182,31 +189,44 @@ async function fetchIndexPatternStats({ const scriptedFields = fields.filter((f) => f.isScript); const runtimeFields = fields.filter((f) => f.runtimeField); - const { body: result } = await client.search({ - index, - body: { - size: SAMPLE_SIZE, - query, - sort: timeFieldName && fromDate && toDate ? [{ [timeFieldName]: 'desc' }] : [], - fields: ['*'], - _source: false, - runtime_mappings: runtimeFields.reduce((acc, field) => { - if (!field.runtimeField) return acc; - // @ts-expect-error @elastic/elasticsearch StoredScript.language is required - acc[field.name] = field.runtimeField; - return acc; - }, {} as Record), - script_fields: scriptedFields.reduce((acc, field) => { - acc[field.name] = { - script: { - lang: field.lang!, - source: field.script!, - }, - }; - return acc; - }, {} as Record), + const { body: result } = await client.search( + { + index, + body: { + size: SAMPLE_SIZE, + query, + // Sorted queries are usually able to skip entire shards that don't match + sort: timeFieldName && fromDate && toDate ? [{ [timeFieldName]: 'desc' }] : [], + fields: ['*'], + _source: false, + runtime_mappings: runtimeFields.reduce((acc, field) => { + if (!field.runtimeField) return acc; + // @ts-expect-error @elastic/elasticsearch StoredScript.language is required + acc[field.name] = field.runtimeField; + return acc; + }, {} as Record), + script_fields: scriptedFields.reduce((acc, field) => { + acc[field.name] = { + script: { + lang: field.lang!, + source: field.script!, + }, + }; + return acc; + }, {} as Record), + // Small improvement because there is overhead in counting + track_total_hits: false, + // Per-shard timeout, must be lower than overall. Shards return partial results on timeout + timeout: '4500ms', + }, }, - }); + { + // Global request timeout. Will cancel the request if exceeded. Overrides the elasticsearch.requestTimeout + requestTimeout: '5000ms', + // Fails fast instead of retrying- default is to retry + maxRetries: 0, + } + ); return result.hits.hits; } diff --git a/x-pack/plugins/license_api_guard/README.md b/x-pack/plugins/license_api_guard/README.md new file mode 100644 index 0000000000000..bf2a9fdff7122 --- /dev/null +++ b/x-pack/plugins/license_api_guard/README.md @@ -0,0 +1,3 @@ +# License API guard plugin + +This plugin is used by ES UI plugins to reject API requests when the plugin is unsupported by the user's license. \ No newline at end of file diff --git a/x-pack/plugins/license_api_guard/jest.config.js b/x-pack/plugins/license_api_guard/jest.config.js new file mode 100644 index 0000000000000..e0f348ceabd85 --- /dev/null +++ b/x-pack/plugins/license_api_guard/jest.config.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/license_api_guard'], +}; diff --git a/x-pack/plugins/license_api_guard/kibana.json b/x-pack/plugins/license_api_guard/kibana.json new file mode 100644 index 0000000000000..0fdf7ffed8988 --- /dev/null +++ b/x-pack/plugins/license_api_guard/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "licenseApiGuard", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["xpack", "licenseApiGuard"], + "server": true, + "ui": false +} diff --git a/x-pack/plugins/security_solution/public/timelines/containers/delete/persist.gql_query.ts b/x-pack/plugins/license_api_guard/server/index.ts similarity index 60% rename from x-pack/plugins/security_solution/public/timelines/containers/delete/persist.gql_query.ts rename to x-pack/plugins/license_api_guard/server/index.ts index 8312988d61e33..3c4abd4e17c30 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/delete/persist.gql_query.ts +++ b/x-pack/plugins/license_api_guard/server/index.ts @@ -5,10 +5,12 @@ * 2.0. */ -import gql from 'graphql-tag'; +export { License } from './license'; -export const deleteTimelineMutation = gql` - mutation DeleteTimelineMutation($id: [ID!]!) { - deleteTimeline(id: $id) - } -`; +/** dummy plugin*/ +export function plugin() { + return new (class LicenseApiGuardPlugin { + setup() {} + start() {} + })(); +} diff --git a/x-pack/plugins/license_api_guard/server/license.test.ts b/x-pack/plugins/license_api_guard/server/license.test.ts new file mode 100644 index 0000000000000..400af7261ff87 --- /dev/null +++ b/x-pack/plugins/license_api_guard/server/license.test.ts @@ -0,0 +1,140 @@ +/* + * 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. + */ + +import { of } from 'rxjs'; +import type { Logger, KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; +import { License } from './license'; +import { LicenseCheckState, licensingMock, LicenseType } from './shared_imports'; + +describe('License API guard', () => { + const pluginName = 'testPlugin'; + + const mockLicensingService = ({ + licenseType, + licenseState, + }: { + licenseType: LicenseType; + licenseState: LicenseCheckState; + }) => { + const licenseMock = licensingMock.createLicenseMock(); + licenseMock.type = licenseType; + licenseMock.check('test', 'gold'); // Flush default mocked state + licenseMock.check.mockReturnValue({ state: licenseState }); // Replace with new mocked state + + return { + license$: of(licenseMock), + }; + }; + + const testRoute = ({ + licenseType, + licenseState, + }: { + licenseType: LicenseType; + licenseState: LicenseCheckState; + }) => { + const license = new License(); + + const logger = { + warn: jest.fn(), + }; + + license.setup({ pluginName, logger }); + const licensing = mockLicensingService({ licenseType, licenseState }); + + license.start({ + pluginId: 'id', + minimumLicenseType: 'gold', + licensing, + }); + + const route = jest.fn(); + const guardedRoute = license.guardApiRoute(route); + const forbidden = jest.fn(); + const responseMock = httpServerMock.createResponseFactory(); + responseMock.forbidden = forbidden; + guardedRoute({} as RequestHandlerContext, {} as KibanaRequest, responseMock); + + return { + errorResponse: + forbidden.mock.calls.length > 0 + ? forbidden.mock.calls[forbidden.mock.calls.length - 1][0] + : undefined, + logMesssage: + logger.warn.mock.calls.length > 0 + ? logger.warn.mock.calls[logger.warn.mock.calls.length - 1][0] + : undefined, + route, + }; + }; + + describe('basic minimum license', () => { + it('is rejected', () => { + const license = new License(); + license.setup({ pluginName, logger: {} as Logger }); + expect(() => { + license.start({ + pluginId: pluginName, + minimumLicenseType: 'basic', + licensing: mockLicensingService({ licenseType: 'gold', licenseState: 'valid' }), + }); + }).toThrowError( + `Basic licenses don't restrict the use of plugins. Please don't use license_api_guard in the ${pluginName} plugin, or provide a more restrictive minimumLicenseType.` + ); + }); + }); + + describe('non-basic minimum license', () => { + const licenseType = 'gold'; + + describe('when valid', () => { + it('the original route is called and nothing is logged', () => { + const { errorResponse, logMesssage, route } = testRoute({ + licenseType, + licenseState: 'valid', + }); + + expect(errorResponse).toBeUndefined(); + expect(logMesssage).toBeUndefined(); + expect(route).toHaveBeenCalled(); + }); + }); + + [ + { + licenseState: 'invalid' as LicenseCheckState, + expectedMessage: `Your ${licenseType} license does not support ${pluginName}. Please upgrade your license.`, + }, + { + licenseState: 'expired' as LicenseCheckState, + expectedMessage: `You cannot use ${pluginName} because your ${licenseType} license has expired.`, + }, + { + licenseState: 'unavailable' as LicenseCheckState, + expectedMessage: `You cannot use ${pluginName} because license information is not available at this time.`, + }, + ].forEach(({ licenseState, expectedMessage }) => { + describe(`when ${licenseState}`, () => { + it('replies with and logs the error message', () => { + const { errorResponse, logMesssage, route } = testRoute({ licenseType, licenseState }); + + // We depend on the call to `response.forbidden()` to generate the 403 status code, + // so we can't assert for it here. + expect(errorResponse).toEqual({ + body: { + message: expectedMessage, + }, + }); + + expect(logMesssage).toBe(expectedMessage); + expect(route).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/license_api_guard/server/license.ts b/x-pack/plugins/license_api_guard/server/license.ts new file mode 100644 index 0000000000000..66e47f02b6e28 --- /dev/null +++ b/x-pack/plugins/license_api_guard/server/license.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + Logger, + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'src/core/server'; + +import { ILicense, LicenseType, LicenseCheckState, LicensingPluginStart } from './shared_imports'; + +type LicenseLogger = Pick; +type LicenseDependency = Pick; + +interface SetupSettings { + pluginName: string; + logger: LicenseLogger; +} + +interface StartSettings { + pluginId: string; + minimumLicenseType: LicenseType; + licensing: LicenseDependency; +} + +export class License { + private pluginName?: string; + private logger?: LicenseLogger; + private licenseCheckState: LicenseCheckState = 'unavailable'; + private licenseType?: LicenseType; + + private _isEsSecurityEnabled: boolean = false; + + setup({ pluginName, logger }: SetupSettings) { + this.pluginName = pluginName; + this.logger = logger; + } + + start({ pluginId, minimumLicenseType, licensing }: StartSettings) { + if (minimumLicenseType === 'basic') { + throw Error( + `Basic licenses don't restrict the use of plugins. Please don't use license_api_guard in the ${pluginId} plugin, or provide a more restrictive minimumLicenseType.` + ); + } + + licensing.license$.subscribe((license: ILicense) => { + this.licenseType = license.type; + this.licenseCheckState = license.check(pluginId, minimumLicenseType!).state; + // Retrieving security checks the results of GET /_xpack as well as license state, + // so we're also checking whether security is disabled in elasticsearch.yml. + this._isEsSecurityEnabled = license.getFeature('security').isEnabled; + }); + } + + private getLicenseErrorMessage(licenseCheckState: LicenseCheckState): string { + switch (licenseCheckState) { + case 'invalid': + return i18n.translate('xpack.licenseApiGuard.license.errorUnsupportedMessage', { + defaultMessage: + 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', + values: { licenseType: this.licenseType!, pluginName: this.pluginName }, + }); + + case 'expired': + return i18n.translate('xpack.licenseApiGuard.license.errorExpiredMessage', { + defaultMessage: + 'You cannot use {pluginName} because your {licenseType} license has expired.', + values: { licenseType: this.licenseType!, pluginName: this.pluginName }, + }); + + case 'unavailable': + return i18n.translate('xpack.licenseApiGuard.license.errorUnavailableMessage', { + defaultMessage: + 'You cannot use {pluginName} because license information is not available at this time.', + values: { pluginName: this.pluginName }, + }); + } + + return i18n.translate('xpack.licenseApiGuard.license.genericErrorMessage', { + defaultMessage: 'You cannot use {pluginName} because the license check failed.', + values: { pluginName: this.pluginName }, + }); + } + + guardApiRoute( + handler: RequestHandler + ) { + return ( + ctx: Context, + request: KibanaRequest, + response: KibanaResponseFactory + ) => { + // We'll only surface license errors if users attempt disallowed access to the API. + if (this.licenseCheckState !== 'valid') { + const licenseErrorMessage = this.getLicenseErrorMessage(this.licenseCheckState); + this.logger?.warn(licenseErrorMessage); + + return response.forbidden({ + body: { + message: licenseErrorMessage, + }, + }); + } + + return handler(ctx, request, response); + }; + } + + public get isEsSecurityEnabled() { + return this._isEsSecurityEnabled; + } +} diff --git a/x-pack/plugins/license_api_guard/server/shared_imports.ts b/x-pack/plugins/license_api_guard/server/shared_imports.ts new file mode 100644 index 0000000000000..1318706df11c9 --- /dev/null +++ b/x-pack/plugins/license_api_guard/server/shared_imports.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { ILicense, LicenseType, LicenseCheckState } from '../../licensing/common/types'; + +export type { LicensingPluginStart } from '../../licensing/server'; + +export { licensingMock } from '../../licensing/server/mocks'; diff --git a/x-pack/plugins/license_api_guard/tsconfig.json b/x-pack/plugins/license_api_guard/tsconfig.json new file mode 100644 index 0000000000000..1b6ea789760d5 --- /dev/null +++ b/x-pack/plugins/license_api_guard/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*" + ], + "references": [ + { "path": "../licensing/tsconfig.json" }, + { "path": "../../../src/core/tsconfig.json" } + ] +} diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 7152d76afbdbe..44e5f9d445c3d 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -82,7 +82,7 @@ export enum SOURCE_TYPES { ES_SEARCH = 'ES_SEARCH', ES_PEW_PEW = 'ES_PEW_PEW', ES_TERM_SOURCE = 'ES_TERM_SOURCE', - EMS_XYZ = 'EMS_XYZ', // identifies a custom TMS source. Name is a little unfortunate. + EMS_XYZ = 'EMS_XYZ', // identifies a custom TMS source. EMS-prefix in the name is a little unfortunate :( WMS = 'WMS', KIBANA_TILEMAP = 'KIBANA_TILEMAP', REGIONMAP_FILE = 'REGIONMAP_FILE', diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index ffedf855c6d9c..aa643b431721c 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -36,6 +36,7 @@ "requiredBundles": [ "kibanaReact", "kibanaUtils", - "home" + "home", + "mapsEms" ] } diff --git a/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/layer_template.test.tsx b/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/layer_template.test.tsx index 93896d50b2b99..6c352b4a39340 100644 --- a/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/layer_template.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/layer_template.test.tsx @@ -31,6 +31,7 @@ const renderWizardArguments = { previewLayers: () => {}, mapColors: [], currentStepId: null, + isOnFinalStep: false, enableNextBtn: () => {}, disableNextBtn: () => {}, startStepLoading: () => {}, diff --git a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/config.tsx b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/config.tsx index 49f35c491ccf0..f914bf79d6a9f 100644 --- a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/config.tsx +++ b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/config.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; -import { ClientFileCreateSourceEditor, INDEX_SETUP_STEP_ID, INDEXING_STEP_ID } from './wizard'; +import { ClientFileCreateSourceEditor, UPLOAD_STEPS } from './wizard'; import { getFileUpload } from '../../../kibana_services'; export const uploadLayerWizardConfig: LayerWizard = { @@ -30,17 +30,23 @@ export const uploadLayerWizardConfig: LayerWizard = { icon: 'importAction', prerequisiteSteps: [ { - id: INDEX_SETUP_STEP_ID, - label: i18n.translate('xpack.maps.fileUploadWizard.importFileSetupLabel', { + id: UPLOAD_STEPS.CONFIGURE_UPLOAD, + label: i18n.translate('xpack.maps.fileUploadWizard.configureUploadLabel', { defaultMessage: 'Import file', }), }, { - id: INDEXING_STEP_ID, - label: i18n.translate('xpack.maps.fileUploadWizard.indexingLabel', { + id: UPLOAD_STEPS.UPLOAD, + label: i18n.translate('xpack.maps.fileUploadWizard.uploadLabel', { defaultMessage: 'Importing file', }), }, + { + id: UPLOAD_STEPS.ADD_DOCUMENT_LAYER, + label: i18n.translate('xpack.maps.fileUploadWizard.configureDocumentLayerLabel', { + defaultMessage: 'Add as document layer', + }), + }, ], renderWizard: (renderWizardArguments: RenderWizardArguments) => { return ; diff --git a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx index a6ff14d20f238..79902cf620511 100644 --- a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx @@ -5,25 +5,25 @@ * 2.0. */ +import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; import { FeatureCollection } from 'geojson'; import { EuiPanel } from '@elastic/eui'; -import { IndexPattern, IFieldType } from 'src/plugins/data/public'; -import { - ES_GEO_FIELD_TYPE, - DEFAULT_MAX_RESULT_WINDOW, - SCALING_TYPES, -} from '../../../../common/constants'; +import { DEFAULT_MAX_RESULT_WINDOW, SCALING_TYPES } from '../../../../common/constants'; import { getFileUpload } from '../../../kibana_services'; import { GeoJsonFileSource } from '../../sources/geojson_file_source'; import { VectorLayer } from '../../layers/vector_layer'; import { createDefaultLayerDescriptor } from '../../sources/es_search_source'; import { RenderWizardArguments } from '../../layers/layer_wizard_registry'; -import { FileUploadComponentProps, ImportResults } from '../../../../../file_upload/public'; +import { FileUploadComponentProps, FileUploadGeoResults } from '../../../../../file_upload/public'; +import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; -export const INDEX_SETUP_STEP_ID = 'INDEX_SETUP_STEP_ID'; -export const INDEXING_STEP_ID = 'INDEXING_STEP_ID'; +export enum UPLOAD_STEPS { + CONFIGURE_UPLOAD = 'CONFIGURE_UPLOAD', + UPLOAD = 'UPLOAD', + ADD_DOCUMENT_LAYER = 'ADD_DOCUMENT_LAYER', +} enum INDEXING_STAGE { READY = 'READY', @@ -35,6 +35,7 @@ enum INDEXING_STAGE { interface State { indexingStage: INDEXING_STAGE | null; fileUploadComponent: React.ComponentType | null; + results?: FileUploadGeoResults; } export class ClientFileCreateSourceEditor extends Component { @@ -56,14 +57,40 @@ export class ClientFileCreateSourceEditor extends Component { + const esSearchSourceConfig = { + indexPatternId: results.indexPatternId, + geoField: results.geoFieldName, + // Only turn on bounds filter for large doc counts + filterByMapBounds: results.docCount > DEFAULT_MAX_RESULT_WINDOW, + scalingType: + results.geoFieldType === ES_FIELD_TYPES.GEO_POINT + ? SCALING_TYPES.CLUSTERS + : SCALING_TYPES.LIMIT, + }; + this.props.previewLayers([ + createDefaultLayerDescriptor(esSearchSourceConfig, this.props.mapColors), + ]); + this.props.advanceToNextStep(); + }); + async _loadFileUploadComponent() { const fileUploadComponent = await getFileUpload().getFileUploadComponent(); if (this._isMounted) { @@ -71,7 +98,7 @@ export class ClientFileCreateSourceEditor extends Component { + _onFileSelect = (geojsonFile: FeatureCollection, name: string, previewCoverage: number) => { if (!this._isMounted) { return; } @@ -103,41 +130,22 @@ export class ClientFileCreateSourceEditor extends Component { + _onFileClear = () => { + this.props.previewLayers([]); + }; + + _onUploadComplete = (results: FileUploadGeoResults) => { if (!this._isMounted) { return; } + this.setState({ results }); + this.setState({ indexingStage: INDEXING_STAGE.SUCCESS }); this.props.advanceToNextStep(); - - const geoField = results.indexPattern.fields.find((field: IFieldType) => - [ES_GEO_FIELD_TYPE.GEO_POINT as string, ES_GEO_FIELD_TYPE.GEO_SHAPE as string].includes( - field.type - ) - ); - if (!results.indexPattern.id || !geoField) { - this.setState({ indexingStage: INDEXING_STAGE.ERROR }); - this.props.previewLayers([]); - } else { - const esSearchSourceConfig = { - indexPatternId: results.indexPattern.id, - geoField: geoField.name, - // Only turn on bounds filter for large doc counts - // @ts-ignore - filterByMapBounds: results.indexDataResp.docCount > DEFAULT_MAX_RESULT_WINDOW, - scalingType: - geoField.type === ES_GEO_FIELD_TYPE.GEO_POINT - ? SCALING_TYPES.CLUSTERS - : SCALING_TYPES.LIMIT, - }; - this.setState({ indexingStage: INDEXING_STAGE.SUCCESS }); - this.props.previewLayers([ - createDefaultLayerDescriptor(esSearchSourceConfig, this.props.mapColors), - ]); - } + this.props.enableNextBtn(); }; - _onIndexingError = () => { + _onUploadError = () => { if (!this._isMounted) { return; } @@ -161,11 +169,6 @@ export class ClientFileCreateSourceEditor extends Component { - this.props.previewLayers([]); - }; - render() { if (!this.state.fileUploadComponent) { return null; @@ -176,11 +179,11 @@ export class ClientFileCreateSourceEditor extends Component ); diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index a73449b0fa718..de889608300bd 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -10,7 +10,7 @@ import { Map as MbMap } from 'mapbox-gl'; import { Query } from 'src/plugins/data/public'; import _ from 'lodash'; -import React, { ReactElement } from 'react'; +import React, { ReactElement, ReactNode } from 'react'; import { EuiIcon } from '@elastic/eui'; import uuid from 'uuid/v4'; import { FeatureCollection } from 'geojson'; @@ -100,7 +100,7 @@ export interface ILayer { } export type CustomIconAndTooltipContent = { - icon: ReactElement | null; + icon: ReactNode; tooltipContent?: string | null; areResultsTrimmed?: boolean; }; diff --git a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts index 2d30acf285d6f..824d9835380ec 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts @@ -16,6 +16,7 @@ export type RenderWizardArguments = { mapColors: string[]; // multi-step arguments for wizards that supply 'prerequisiteSteps' currentStepId: string | null; + isOnFinalStep: boolean; enableNextBtn: () => void; disableNextBtn: () => void; startStepLoading: () => void; diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 2915eaec8ac77..50043772af95b 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -167,12 +167,6 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - const requestResponder = this.getInspectorAdapters()?.requests?.start(requestName, { - id: requestId, - description: requestDescription, - searchSessionId, - }); - let resp; try { resp = await searchSource @@ -180,7 +174,12 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource abortSignal: abortController.signal, sessionId: searchSessionId, legacyHitsTotal: false, - requestResponder, + inspector: { + adapter: this.getInspectorAdapters()?.requests, + id: requestId, + title: requestName, + description: requestDescription, + }, }) .toPromise(); } catch (error) { diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/flyout_body.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/flyout_body.tsx index 4facfe72a6c6a..bcc7bbae8a9cc 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/flyout_body.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/flyout_body.tsx @@ -28,6 +28,7 @@ export const FlyoutBody = (props: Props) => { previewLayers: props.previewLayers, mapColors: props.mapColors, currentStepId: props.currentStepId, + isOnFinalStep: props.isOnFinalStep, enableNextBtn: props.enableNextBtn, disableNextBtn: props.disableNextBtn, startStepLoading: props.startStepLoading, diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx index 35672d7369404..0774798eab46d 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx @@ -168,6 +168,9 @@ export class AddLayerPanel extends Component { previewLayers={this._previewLayers} showBackButton={!this.state.isStepLoading} currentStepId={this.state.currentStep ? this.state.currentStep.id : null} + isOnFinalStep={ + this.state.currentStep ? this.state.currentStep.id === ADD_LAYER_STEP_ID : false + } enableNextBtn={this._enableNextBtn} disableNextBtn={this._disableNextBtn} startStepLoading={this._startStepLoading} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/tooltip_header.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.js.snap similarity index 63% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/tooltip_header.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.js.snap index b5fe334f8415e..6840456741e03 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/tooltip_header.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.js.snap @@ -1,7 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TooltipHeader multiple features, multiple layers: locked should show pagination controls, features count, layer select, and close button 1`] = ` +exports[`Footer multiple features, multiple layers: locked should show pagination controls, features count, and layer select 1`] = ` + - - - - `; -exports[`TooltipHeader multiple features, multiple layers: mouseover (unlocked) should only show features count 1`] = ` +exports[`Footer multiple features, multiple layers: mouseover (unlocked) should only show features count 1`] = ` + - `; -exports[`TooltipHeader multiple features, single layer: locked should show pagination controls, features count, and close button 1`] = ` +exports[`Footer multiple features, single layer: locked should show pagination controls and features count 1`] = ` + - - - - `; -exports[`TooltipHeader multiple features, single layer: mouseover (unlocked) should only show features count 1`] = ` +exports[`Footer multiple features, single layer: mouseover (unlocked) should only show features count 1`] = ` + - - -`; - -exports[`TooltipHeader single feature: locked should show close button when locked 1`] = ` - - - - - - - - `; -exports[`TooltipHeader single feature: mouseover (unlocked) should not render header 1`] = `""`; +exports[`Footer single feature: mouseover (unlocked) should not render header 1`] = `""`; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/header.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/header.test.tsx.snap new file mode 100644 index 0000000000000..db4a2640357bf --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/header.test.tsx.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`isLocked 1`] = ` + + + + + mockIcon + + + + +

+ myLayerName +

+
+
+ + + +
+ +
+`; + +exports[`render 1`] = ` + + + + + mockIcon + + + + +

+ myLayerName +

+
+
+
+ +
+`; + +exports[`should only show close button when layer name is not yet loaded 1`] = ` + + + + + + + + + +`; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/_index.scss b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/_index.scss index abd747c8fa47a..92df0ffbaad92 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/_index.scss @@ -30,3 +30,9 @@ justify-content: flex-end; } } + +.mapFeatureTooltip_layerIcon { + img { + margin-bottom: 0; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js index 48534f8bcd3ac..be8e960471efa 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js @@ -10,7 +10,8 @@ import { EuiIcon, EuiLink } from '@elastic/eui'; import { FeatureProperties } from './feature_properties'; import { GEO_JSON_TYPE, ES_GEO_FIELD_TYPE } from '../../../../common/constants'; import { FeatureGeometryFilterForm } from './feature_geometry_filter_form'; -import { TooltipHeader } from './tooltip_header'; +import { Footer } from './footer'; +import { Header } from './header'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -167,12 +168,12 @@ export class FeaturesTooltip extends Component { return ( - {this._renderActions(geoFields)} +