diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.js b/.buildkite/scripts/steps/storybooks/build_and_upload.js index 0fdf24d87ffad..89958fe08d6cc 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.js +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.js @@ -8,6 +8,7 @@ const STORYBOOKS = [ 'canvas', 'codeeditor', 'ci_composite', + 'custom_integrations', 'url_template_editor', 'dashboard', 'dashboard_enhanced', diff --git a/.ci/.storybook/main.js b/.ci/.storybook/main.js index e399ec087e168..37f3391337308 100644 --- a/.ci/.storybook/main.js +++ b/.ci/.storybook/main.js @@ -11,6 +11,12 @@ const aliases = require('../../src/dev/storybook/aliases.ts').storybookAliases; config.refs = {}; +// Required due to https://github.com/storybookjs/storybook/issues/13834 +config.babel = async (options) => ({ + ...options, + plugins: ['@babel/plugin-transform-typescript', ...options.plugins], +}); + for (const alias of Object.keys(aliases).filter((a) => a !== 'ci_composite')) { // snake_case -> Title Case const title = alias diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 149f5cd74d8c0..244689025173f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -243,7 +243,6 @@ /packages/kbn-std/ @elastic/kibana-core /packages/kbn-config/ @elastic/kibana-core /packages/kbn-logging/ @elastic/kibana-core -/packages/kbn-legacy-logging/ @elastic/kibana-core /packages/kbn-crypto/ @elastic/kibana-core /packages/kbn-http-tools/ @elastic/kibana-core /src/plugins/saved_objects_management/ @elastic/kibana-core diff --git a/.i18nrc.json b/.i18nrc.json index 45016edc38dcd..46d2f8c6a23bf 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -5,6 +5,7 @@ "kbnConfig": "packages/kbn-config/src", "console": "src/plugins/console", "core": "src/core", + "customIntegrations": "src/plugins/custom_integrations", "discover": "src/plugins/discover", "bfetch": "src/plugins/bfetch", "dashboard": "src/plugins/dashboard", @@ -18,6 +19,7 @@ "home": "src/plugins/home", "flot": "packages/kbn-ui-shared-deps-src/src/flot_charts", "charts": "src/plugins/charts", + "customIntegrations": "src/plugins/custom_integrations", "esUi": "src/plugins/es_ui_shared", "devTools": "src/plugins/dev_tools", "expressions": "src/plugins/expressions", diff --git a/config/kibana.yml b/config/kibana.yml index dea9849f17b28..13a4b9bb98e85 100644 --- a/config/kibana.yml +++ b/config/kibana.yml @@ -84,24 +84,32 @@ # Time in milliseconds for Elasticsearch to wait for responses from shards. Set to 0 to disable. #elasticsearch.shardTimeout: 30000 -# Logs queries sent to Elasticsearch. Requires logging.verbose set to true. -#elasticsearch.logQueries: false - # Specifies the path where Kibana creates the process ID file. #pid.file: /run/kibana/kibana.pid +# Set the value of this setting to off to suppress all logging output, or to debug to log everything. +# logging.root.level: debug + # Enables you to specify a file where Kibana stores log output. -#logging.dest: stdout +# logging.appenders.default: +# type: file +# fileName: /var/logs/kibana.log + -# Set the value of this setting to true to suppress all logging output. -#logging.silent: false +# Logs queries sent to Elasticsearch. +# logging.loggers: +# - name: elasticsearch.queries +# level: debug -# Set the value of this setting to true to suppress all logging output other than error messages. -#logging.quiet: false +# Logs http responses. +# logging.loggers: +# - name: http.server.response +# level: debug -# Set the value of this setting to true to log all events, including system usage information -# and all requests. -#logging.verbose: false +# Logs system usage information. +# logging.loggers: +# - name: metrics.ops +# level: debug # Set the interval in milliseconds to sample system and process performance # metrics. Minimum is 100ms. Defaults to 5000. diff --git a/dev_docs/assets/1000_ft_arch.png b/dev_docs/assets/1000_ft_arch.png new file mode 100644 index 0000000000000..715c830606d76 Binary files /dev/null and b/dev_docs/assets/1000_ft_arch.png differ diff --git a/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx b/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx index b2255dbc8e5c4..133b96f44da88 100644 --- a/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx +++ b/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx @@ -1,7 +1,7 @@ --- id: kibPlatformIntro slug: /kibana-dev-docs/key-concepts/platform-intro -title: Plugins and the Kibana platform +title: Plugins, packages, and the platform summary: An introduction to the Kibana platform and how to use it to build a plugin. date: 2021-01-06 tags: ['kibana', 'onboarding', 'dev', 'architecture'] @@ -17,30 +17,47 @@ already existing applications. Did you know that almost everything you see in th Kibana UI is built inside a plugin? If you removed all plugins from Kibana, you'd be left with an empty navigation menu, and a set of developer tools. The Kibana platform is a blank canvas, just waiting for a developer to come along and create something! -![Kibana personas](assets/kibana_platform_plugin_end_user.png) +![Kibana personas](../assets/kibana_platform_plugin_end_user.png) -## Platform services +## 1,000 foot view -Plugins have access to three kinds of public services: +At a super high-level, Kibana is composed of **plugins**, **core**, and **Kibana packages**. -- Platform services provided by `core` () -- Platform services provided by plugins () -- Shared services provided by plugins, that are only relevant for only a few, specific plugins (e.g. "presentation utils"). +![Kibana 1000 ft arch](../assets/1000_ft_arch.png) -The first two items are what make up "Platform services". +**Plugins** provide the majority of all functionality in Kibana. All applications and UIs are defined here. - +**Core** provides the runtime and the most fundamental services. -We try to put only the most stable and fundamental code into `Core`, while more application focused functionality goes in a plugin, but the heuristic isn't -clear, and we haven't done a great job of sticking to it. For example, notifications and toasts are core services, but data and search are plugin services. +**@kbn packages** provide static utilities that can be imported anywhere in Kibana. + + + +If it's stateful, it has to go in a plugin, but packages are often a good choices for stateless utilities. Stateless code exported publicly from a plugin will increase the page load bundle size of _every single page_, even if none of those plugin's services are actually needed. With packages, however, only code that is needed for the current page is downloaded. + +The downside however is that the packages folder is far away from the plugins folder so having a part of your code in a plugin and the rest in a package may make it hard to find, leading to duplication. + +The Operations team hopes to resolve this conundrum by supporting co-located packages and plugins and automatically putting all stateless code inside a package. You can track this work by following [this issue](https://github.com/elastic/kibana/issues/112886). + +Until then, consider whether it makes sense to logically separate the code, and consider the size of the exports, when determining whether you should put stateless public exports in a package or a plugin. + + + + + + + +We try to put only the most stable and fundamental code into `Core`, while optional add-ons, applications, and solution-oriented functionality goes in a plugin. Unfortunately, we haven't done a great job of sticking to that. For example, notifications and toasts are core services, but data and search are plugin services. Today it looks something like this. -![Core vs platform plugins vs plugins](assets/platform_plugins_core.png) +![Core vs platform plugins vs plugins](../assets/platform_plugins_core.png) + +"Platform plugins" provide core-like functionality, just outside of core, and their public APIs tend to be more volatile. Other plugins may still expose shared services, but they are intended only for usage by a small subset of specific plugins, and may not be generic or "platform-like". - -When the Kibana platform and plugin infrastructure was built, we thought of two types of code: core services, and other plugin services. We planned to keep the most stable and fundamental -code needed to build plugins inside core. +**A bit of history** + +When the Kibana platform and plugin infrastructure was built, we thought of two types of code: core services, and other plugin services. We planned to keep the most stable and fundamental code needed to build plugins inside core. In reality, we ended up with many platform-like services living outside of core, with no (short term) intention of moving them. We highly encourage plugin developers to use them, so we consider them part of platform services. @@ -49,36 +66,18 @@ When we built our platform system, we also thought we'd end up with only a handf footprint and speed up Kibana. In reality, our plugin model ended up being used like micro-services. Plugins are the only form of encapsulation we provide developers, and they liked it! However, we ended -up with a ton of small plugins, that developers never intended to be uninstallable, nor tested in this manner. We are considering ways to provide developers the ability to build services -with the encapsulation -they desire, without the need to build a plugin. +up with a ton of small plugins, that developers never intended to be uninstallable, nor tested in this manner. We are considering ways to provide developers the ability to build services with the encapsulation they desire, without the need to build a plugin. Another side effect of having many small plugins is that common code often ends up extracted into another plugin. Use case specific utilities are exported, that are not meant to be used in a general manner. This makes our definition of "platform code" a bit trickier to define. We'd like to say "The platform is made up of -every publically exposed service", but in today's world, that wouldn't be a very accurate picture. +every publicly exposed service", but in today's world, that wouldn't be a very accurate picture. We recognize the need to better clarify the relationship between core functionality, platform-like plugin functionality, and functionality exposed by other plugins. It's something we will be working on! - - We will continue to focus on adding clarity around these types of services and what developers can expect from each. - - -### Core services - -Sometimes referred to just as provide the most basic and fundamental tools neccessary for building a plugin, like creating saved objects, -routing, application registration, notifications and . The Core platform is not a plugin itself, although -there are some plugins that provide platform functionality. We call these . - -### Platform plugins - -Plugins that provide fundamental services and functionality to extend and customize Kibana, for example, the - - plugin. There is no official way to tell if a plugin is a -platform plugin or not. Platform plugins are _usually_ plugins that are managed by the Platform Group, -but we are starting to see some exceptions. + ## Plugins @@ -92,7 +91,7 @@ A plugin may register many applications, or none. Applications are top level pages in the Kibana UI. Dashboard, Canvas, Maps, App Search, etc, are all examples of applications: -![applications in kibana](./assets/applications.png) +![applications in kibana](../assets/applications.png) A plugin can register an application by adding it to core's application . diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index 4a62f71528676..6e0c3b1decda8 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -72,8 +72,6 @@ then the index template will not be set up automatically. Instead, you'll need t *Using a custom index names* This problem can also occur if you've customized the index name that you write APM data to. -The default index name that APM writes events to can be found -{apm-server-ref}/elasticsearch-output.html#index-option-es[here]. If you change the default, you must also configure the `setup.template.name` and `setup.template.pattern` options. See {apm-server-ref}/configuration-template.html[Load the Elasticsearch index template]. If the Elasticsearch index template has already been successfully loaded to the index, diff --git a/docs/dev-tools/grokdebugger/index.asciidoc b/docs/dev-tools/grokdebugger/index.asciidoc index 934452c54ccca..6a809c13fcb93 100644 --- a/docs/dev-tools/grokdebugger/index.asciidoc +++ b/docs/dev-tools/grokdebugger/index.asciidoc @@ -9,21 +9,22 @@ structure it. Grok is good for parsing syslog, apache, and other webserver logs, mysql logs, and in general, any log format that is written for human consumption. -Grok patterns are supported in the ingest node -{ref}/grok-processor.html[grok processor] and the Logstash -{logstash-ref}/plugins-filters-grok.html[grok filter]. See -{logstash-ref}/plugins-filters-grok.html#_grok_basics[grok basics] -for more information on the syntax for a grok pattern. - -The Elastic Stack ships -with more than 120 reusable grok patterns. See -https://github.com/elastic/elasticsearch/tree/master/libs/grok/src/main/resources/patterns[Ingest node grok patterns] and https://github.com/logstash-plugins/logstash-patterns-core/tree/master/patterns[Logstash grok patterns] -for the complete list of patterns. +Grok patterns are supported in {es} {ref}/runtime.html[runtime fields], the {es} +{ref}/grok-processor.html[grok ingest processor], and the {ls} +{logstash-ref}/plugins-filters-grok.html[grok filter]. For syntax, see +{ref}/grok.html[Grokking grok]. + +The {stack} ships with more than 120 reusable grok patterns. For a complete +list of patterns, see +https://github.com/elastic/elasticsearch/tree/master/libs/grok/src/main/resources/patterns[{es} +grok patterns] and +https://github.com/logstash-plugins/logstash-patterns-core/tree/master/patterns[{ls} +grok patterns]. Because -ingest node and Logstash share the same grok implementation and pattern +{es} and {ls} share the same grok implementation and pattern libraries, any grok pattern that you create in the *Grok Debugger* will work -in ingest node and Logstash. +in both {es} and {ls}. [float] [[grokdebugger-getting-started]] diff --git a/docs/developer/architecture/core/logging-configuration-migration.asciidoc b/docs/developer/architecture/core/logging-configuration-migration.asciidoc index 19f10a881d5e8..db02b4d4e507f 100644 --- a/docs/developer/architecture/core/logging-configuration-migration.asciidoc +++ b/docs/developer/architecture/core/logging-configuration-migration.asciidoc @@ -76,9 +76,5 @@ you can override the flags with: |--verbose| --logging.root.level=debug --logging.root.appenders[0]=default --logging.root.appenders[1]=custom | --verbose -|--quiet| --logging.root.level=error --logging.root.appenders[0]=default --logging.root.appenders[1]=custom | not supported - |--silent| --logging.root.level=off | --silent |=== - -NOTE: To preserve backwards compatibility, you are required to pass the root `default` appender until the legacy logging system is removed in `v8.0`. diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index b42bc980c8758..7754463339771 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -74,7 +74,6 @@ yarn kbn watch - @kbn/i18n - @kbn/interpreter - @kbn/io-ts-utils -- @kbn/legacy-logging - @kbn/logging - @kbn/mapbox-gl - @kbn/monaco diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 7f7041f7815cd..cbf46801fa86f 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -458,7 +458,7 @@ the infrastructure monitoring use-case within Kibana. |{kib-repo}blob/{branch}/x-pack/plugins/ingest_pipelines/README.md[ingestPipelines] -|The ingest_pipelines plugin provides Kibana support for Elasticsearch's ingest nodes. Please refer to the Elasticsearch documentation for more details. +|The ingest_pipelines plugin provides Kibana support for Elasticsearch's ingest pipelines. |{kib-repo}blob/{branch}/x-pack/plugins/lens/readme.md[lens] 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 f40f52db55de9..ab0f2d0ee5a17 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 @@ -234,5 +234,17 @@ readonly links: { readonly ecs: { readonly guide: string; }; + readonly clients: { + readonly guide: string; + readonly goOverview: string; + readonly javaIndex: string; + readonly jsIntro: string; + readonly netGuide: string; + readonly perlGuide: string; + readonly phpGuide: string; + readonly pythonGuide: string; + readonly rubyOverview: string; + readonly rustGuide: 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 2499227d20ad4..f0fe058c403ed 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 settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: 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 libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: string;
}>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: 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 libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: string;
}>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | diff --git a/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md deleted file mode 100644 index b6d1f9386be8f..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) > [domainId](./kibana-plugin-core-public.domaindeprecationdetails.domainid.md) - -## DomainDeprecationDetails.domainId property - -Signature: - -```typescript -domainId: string; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md deleted file mode 100644 index 93d715a11c503..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md +++ /dev/null @@ -1,18 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) - -## DomainDeprecationDetails interface - -Signature: - -```typescript -export interface DomainDeprecationDetails extends DeprecationsDetails -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [domainId](./kibana-plugin-core-public.domaindeprecationdetails.domainid.md) | string | | - diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 08c3c376df4e8..e5fbe7c3524ed 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -60,7 +60,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DeprecationsServiceStart](./kibana-plugin-core-public.deprecationsservicestart.md) | DeprecationsService provides methods to fetch domain deprecation details from the Kibana server. | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | -| [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) | | | [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | | [FatalErrorInfo](./kibana-plugin-core-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | diff --git a/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md b/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md index f6de959589eca..7d9772af91c38 100644 --- a/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md +++ b/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; +export declare type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.correctiveactions.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.correctiveactions.md index 447823a5c3491..657c62a21c581 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.correctiveactions.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.correctiveactions.md @@ -4,6 +4,8 @@ ## DeprecationsDetails.correctiveActions property +corrective action needed to fix this deprecation. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.documentationurl.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.documentationurl.md index 467d6d76cf842..457cf7b61dac8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.documentationurl.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.documentationurl.md @@ -4,6 +4,8 @@ ## DeprecationsDetails.documentationUrl property +(optional) link to the documentation for more details on the deprecation. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md index bd0fc1e5b3713..86418a1d0c1c3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md @@ -4,6 +4,7 @@ ## DeprecationsDetails interface + Signature: ```typescript @@ -14,11 +15,11 @@ export interface DeprecationsDetails | Property | Type | Description | | --- | --- | --- | -| [correctiveActions](./kibana-plugin-core-server.deprecationsdetails.correctiveactions.md) | {
api?: {
path: string;
method: 'POST' | 'PUT';
body?: {
[key: string]: any;
};
};
manualSteps: string[];
} | | +| [correctiveActions](./kibana-plugin-core-server.deprecationsdetails.correctiveactions.md) | {
api?: {
path: string;
method: 'POST' | 'PUT';
body?: {
[key: string]: any;
};
};
manualSteps: string[];
} | corrective action needed to fix this deprecation. | | [deprecationType](./kibana-plugin-core-server.deprecationsdetails.deprecationtype.md) | 'config' | 'feature' | (optional) Used to identify between different deprecation types. Example use case: in Upgrade Assistant, we may want to allow the user to sort by deprecation type or show each type in a separate tab.Feel free to add new types if necessary. Predefined types are necessary to reduce having similar definitions with different keywords across kibana deprecations. | -| [documentationUrl](./kibana-plugin-core-server.deprecationsdetails.documentationurl.md) | string | | +| [documentationUrl](./kibana-plugin-core-server.deprecationsdetails.documentationurl.md) | string | (optional) link to the documentation for more details on the deprecation. | | [level](./kibana-plugin-core-server.deprecationsdetails.level.md) | 'warning' | 'critical' | 'fetch_error' | levels: - warning: will not break deployment upon upgrade - critical: needs to be addressed before upgrade. - fetch\_error: Deprecations service failed to grab the deprecation details for the domain. | | [message](./kibana-plugin-core-server.deprecationsdetails.message.md) | string | The description message to be displayed for the deprecation. Check the README for writing deprecations in src/core/server/deprecations/README.mdx | -| [requireRestart](./kibana-plugin-core-server.deprecationsdetails.requirerestart.md) | boolean | | +| [requireRestart](./kibana-plugin-core-server.deprecationsdetails.requirerestart.md) | boolean | (optional) specify the fix for this deprecation requires a full kibana restart. | | [title](./kibana-plugin-core-server.deprecationsdetails.title.md) | string | The title of the deprecation. Check the README for writing deprecations in src/core/server/deprecations/README.mdx | diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.requirerestart.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.requirerestart.md index 52c0fcf1c3001..85bddd9436e73 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.requirerestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.requirerestart.md @@ -4,6 +4,8 @@ ## DeprecationsDetails.requireRestart property +(optional) specify the fix for this deprecation requires a full kibana restart. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md index 2bc7f6cba594d..7b2cbdecd146a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md @@ -27,7 +27,6 @@ async function getDeprecations({ esClient, savedObjectsClient }: GetDeprecations const deprecations: DeprecationsDetails[] = []; const count = await getFooCount(savedObjectsClient); if (count > 0) { - // Example of a manual correctiveAction deprecations.push({ title: i18n.translate('xpack.foo.deprecations.title', { defaultMessage: `Foo's are deprecated` diff --git a/docs/development/core/server/kibana-plugin-core-server.getdeprecationscontext.md b/docs/development/core/server/kibana-plugin-core-server.getdeprecationscontext.md index 1018444f0849a..96dd2ceb524ce 100644 --- a/docs/development/core/server/kibana-plugin-core-server.getdeprecationscontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.getdeprecationscontext.md @@ -4,6 +4,7 @@ ## GetDeprecationsContext interface + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.registerdeprecationsconfig.md b/docs/development/core/server/kibana-plugin-core-server.registerdeprecationsconfig.md index 59e6d406f84bf..444c2653512de 100644 --- a/docs/development/core/server/kibana-plugin-core-server.registerdeprecationsconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.registerdeprecationsconfig.md @@ -4,6 +4,7 @@ ## RegisterDeprecationsConfig interface + Signature: ```typescript diff --git a/docs/migration/migrate_8_0.asciidoc b/docs/migration/migrate_8_0.asciidoc index 4c0c335b3c33e..60a65580501a6 100644 --- a/docs/migration/migrate_8_0.asciidoc +++ b/docs/migration/migrate_8_0.asciidoc @@ -364,4 +364,34 @@ Configuration management tools and automation will need to be updated to use the === `server.xsrf.token` is no longer valid *Details:* The deprecated `server.xsrf.token` setting in the `kibana.yml` file has been removed. +[float] +=== `newsfeed.defaultLanguage` is no longer valid +*Details:* Specifying a default language to retrieve newsfeed items is no longer supported. + +*Impact:* Newsfeed items will be retrieved based on the browser locale and fallback to 'en' if an item does not have a translation for the locale. Configure {kibana-ref}/i18n-settings-kb.html#general-i18n-settings-kb[`i18n.locale`] to override the default behavior. + +[float] +=== `xpack.banners.placement` has changed value +*Details:* `xpack.banners.placement: 'header'` setting in `kibana.yml` has changed value. + +*Impact:* Use {kibana-ref}/banners-settings-kb.html#banners-settings-kb[`xpack.banners.placement: 'top'`] instead. + +[float] +=== `cpu.cgroup.path.override` is no longer valid +*Details:* The deprecated `cpu.cgroup.path.override` setting is no longer supported. + +*Impact:* Configure {kibana-ref}/settings.html#ops-cGroupOverrides-cpuPath[`ops.cGroupOverrides.cpuPath`] instead. + +[float] +=== `cpuacct.cgroup.path.override` is no longer valid +*Details:* The deprecated `cpuacct.cgroup.path.override` setting is no longer supported. + +*Impact:* Configure {kibana-ref}/settings.html#ops-cGroupOverrides-cpuAcctPath[`ops.cGroupOverrides.cpuAcctPath`] instead. + +[float] +=== `server.xsrf.whitelist` is no longer valid +*Details:* The deprecated `server.xsrf.whitelist` setting is no longer supported. + +*Impact:* Use {kibana-ref}/settings.html#settings-xsrf-allowlist[`server.xsrf.allowlist`] instead. + // end::notable-breaking-changes[] diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index 97a87506f2337..d5bc2ccd8ef7d 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -293,7 +293,7 @@ This content has moved. Refer to <>. This content has moved. Refer to <>. [role="exclude",id="ingest-node-pipelines"] -== Ingest Node Pipelines +== Ingest Pipelines This content has moved. Refer to {ref}/ingest.html[Ingest pipelines]. diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 92357a8800d67..3a94e652d2ea0 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -31,11 +31,6 @@ Be sure to back up the encryption key value somewhere safe, as your alerting rul [[action-settings]] ==== Action settings -`xpack.actions.enabled`:: -deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] -Feature toggle that enables Actions in {kib}. -If `false`, all features dependent on Actions are disabled, including the *Observability* and *Security* apps. Default: `true`. - `xpack.actions.allowedHosts` {ess-icon}:: A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + @@ -179,3 +174,10 @@ For example, `20m`, `24h`, `7d`, `1w`. Default: `60s`. `xpack.alerting.maxEphemeralActionsPerAlert`:: Sets the number of actions that will be executed ephemerally. To use this, enable ephemeral tasks in task manager first with <> + +`xpack.alerting.defaultRuleTaskTimeout`:: +Specifies the default timeout for the all rule types tasks. The time is formatted as: ++ +`[ms,s,m,h,d,w,M,Y]` ++ +For example, `20m`, `24h`, `7d`, `1w`. Default: `60s`. diff --git a/docs/settings/logging-settings.asciidoc b/docs/settings/logging-settings.asciidoc index 77f3bd90a911a..177d1bc8db118 100644 --- a/docs/settings/logging-settings.asciidoc +++ b/docs/settings/logging-settings.asciidoc @@ -12,16 +12,6 @@ Refer to the <> for common configuration use cases. To learn more about possible configuration values, go to {kibana-ref}/logging-service.html[{kib}'s Logging service]. -[[log-settings-compatibility]] -==== Backwards compatibility -Compatibility with the legacy logging system is assured until the end of the `v7` version. -All log messages handled by `root` context (default) are forwarded to the legacy logging service. -The logging configuration is validated against the predefined schema and if there are -any issues with it, {kib} will fail to start with the detailed error message. - -NOTE: When you switch to the new logging configuration, you will start seeing duplicate log entries in both formats. -These will be removed when the `default` appender is no longer required. - [[log-settings-examples]] ==== Examples Here are some configuration examples for the most common logging use cases: diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 694f8c53f6745..560f2d850c6d5 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -11,7 +11,6 @@ You can configure `xpack.reporting` settings in your `kibana.yml` to: * <> * <> -* <> * <> * <> * <> @@ -47,33 +46,6 @@ The static encryption key for reporting. Use an alphanumeric text string that is xpack.reporting.encryptionKey: "something_secret" -------------------------------------------------------------------------------- -[float] -[[report-indices]] -==== Reporting index setting - - - -`xpack.reporting.index`:: -deprecated:[7.11.0,This setting will be removed in 8.0.0.] Multitenancy by changing `kibana.index` is unsupported starting in 8.0.0. For more details, refer to https://ela.st/kbn-remove-legacy-multitenancy[8.0 Breaking Changes]. When you divide workspaces in an Elastic cluster using multiple {kib} instances with a different `kibana.index` setting per instance, you must set a unique `xpack.reporting.index` setting per `kibana.index`. Otherwise, report generation periodically fails if a report is queued through an instance with one `kibana.index` setting, and an instance with a different `kibana.index` attempts to claim the job. Reporting uses a weekly index in {es} to store the reporting job and the report content. The index is automatically created if it does not already exist. Configure a unique value for `xpack.reporting.index`, beginning with `.reporting-`, for every {kib} instance that has a unique <> setting. Defaults to `.reporting`. - -{kib} instance A: -[source,yaml] --------------------------------------------------------------------------------- -kibana.index: ".kibana-a" -xpack.reporting.index: ".reporting-a" -xpack.reporting.encryptionKey: "something_secret" --------------------------------------------------------------------------------- - -{kib} instance B: -[source,yaml] --------------------------------------------------------------------------------- -kibana.index: ".kibana-b" -xpack.reporting.index: ".reporting-b" -xpack.reporting.encryptionKey: "something_secret" --------------------------------------------------------------------------------- - -NOTE: If security is enabled, the `xpack.reporting.index` setting should begin with `.reporting-` for the `kibana_system` role to have the necessary privileges over the index. - [float] [[reporting-kibana-server-settings]] ==== {kib} server settings diff --git a/docs/setup/configuring-reporting.asciidoc b/docs/setup/configuring-reporting.asciidoc index 6d209092d3338..38bf2955fb56e 100644 --- a/docs/setup/configuring-reporting.asciidoc +++ b/docs/setup/configuring-reporting.asciidoc @@ -148,56 +148,6 @@ reporting_user: - "cn=Bill Murray,dc=example,dc=com" -------------------------------------------------------------------------------- -[float] -==== Grant access with a custom index - -If you are using a custom index, the `xpack.reporting.index` setting must begin with `.reporting-*`. The default {kib} system user has `all` privileges against the `.reporting-*` pattern of indices. - -If you use a different pattern for the `xpack.reporting.index` setting, you must create a custom `kibana_system` user with appropriate access to the index. - -NOTE: In the next major version of Kibana, granting access with a custom index is unsupported. - -. Create the reporting role. - -.. Open the main menu, then click *Stack Management*. - -.. Click *Roles > Create role*. - -. Specify the role settings. - -.. Enter the *Role name*. For example, `custom-reporting-user`. - -.. From the *Indices* dropdown, select the custom index. - -.. From the *Privileges* dropdown, select *all*. - -.. Click *Add Kibana privilege*. - -.. Select one or more *Spaces* that you want to grant reporting privileges to. - -.. Click *Customize*, then click *Analytics*. - -.. Next to each application you want to grant reporting privileges to, click *All*. - -.. Click *Add {kib} privilege*, then click *Create role*. - -. Assign the reporting role to a user. - -.. Open the main menu, then click *Stack Management*. - -.. Click *Users*, then click the user you want to assign the reporting role to. - -.. From the *Roles* dropdown, select *kibana_system* and *custom-reporting-user*. - -.. Click *Update user*. - -. Configure {kib} to use the new account. -+ -[source,js] --------------------------------------------------------------------------------- -elasticsearch.username: 'custom_kibana_system' --------------------------------------------------------------------------------- - [float] [[securing-reporting]] === Secure the reporting endpoints diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index c098fb697de04..7a85411065db6 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -26,16 +26,6 @@ Toggling this causes the server to regenerate assets on the next startup, 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,"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,"In 8.0 and later, this setting will no longer be supported."] - This setting has been renamed to - <>. - | `csp.rules:` | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] A https://w3c.github.io/webappsec-csp/[Content Security Policy] template diff --git a/docs/user/management.asciidoc b/docs/user/management.asciidoc index 4e5f70db9aef6..1f38d50e2d0bd 100644 --- a/docs/user/management.asciidoc +++ b/docs/user/management.asciidoc @@ -17,7 +17,7 @@ Consult your administrator if you do not have the appropriate access. [cols="50, 50"] |=== -| {ref}/ingest.html[Ingest Node Pipelines] +| {ref}/ingest.html[Ingest Pipelines] | Create and manage ingest pipelines that let you perform common transformations and enrichments on your data. diff --git a/docs/user/monitoring/monitoring-metricbeat.asciidoc b/docs/user/monitoring/monitoring-metricbeat.asciidoc index 5ef3b8177a9c5..101377e047588 100644 --- a/docs/user/monitoring/monitoring-metricbeat.asciidoc +++ b/docs/user/monitoring/monitoring-metricbeat.asciidoc @@ -189,8 +189,9 @@ If you configured the monitoring cluster to use encrypted communications, you must access it via HTTPS. For example, use a `hosts` setting like `https://es-mon-1:9200`. -IMPORTANT: The {es} {monitor-features} use ingest pipelines, therefore the -cluster that stores the monitoring data must have at least one ingest node. +IMPORTANT: The {es} {monitor-features} use ingest pipelines. The +cluster that stores the monitoring data must have at least one node with the +`ingest` role. If the {es} {security-features} are enabled on the monitoring cluster, you must provide a valid user ID and password so that {metricbeat} can send metrics diff --git a/docs/user/production-considerations/production.asciidoc b/docs/user/production-considerations/production.asciidoc index 455e07e452807..db8d0738323ff 100644 --- a/docs/user/production-considerations/production.asciidoc +++ b/docs/user/production-considerations/production.asciidoc @@ -32,12 +32,21 @@ server.name Settings unique across each host (for example, running multiple installations on the same virtual machine): [source,js] -------- -logging.dest path.data pid.file server.port -------- +When using a file appender, the target file must also be unique: +[source,yaml] +-------- +logging: + appenders: + default: + type: file + fileName: /unique/path/per/instance +-------- + Settings that must be the same: [source,js] -------- diff --git a/package.json b/package.json index f04a8423196fd..6539189ca994d 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,9 @@ "yarn": "^1.21.1" }, "dependencies": { + "@dnd-kit/core": "^3.1.1", + "@dnd-kit/sortable": "^4.0.0", + "@dnd-kit/utilities": "^2.0.0", "@babel/runtime": "^7.15.4", "@elastic/apm-rum": "^5.9.1", "@elastic/apm-rum-react": "^1.3.1", @@ -101,7 +104,6 @@ "@elastic/ems-client": "7.15.0", "@elastic/eui": "38.0.1", "@elastic/filesaver": "1.1.2", - "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", @@ -113,12 +115,10 @@ "@hapi/accept": "^5.0.2", "@hapi/boom": "^9.1.4", "@hapi/cookie": "^11.0.2", - "@hapi/good-squeeze": "6.0.0", "@hapi/h2o2": "^9.1.0", "@hapi/hapi": "^20.2.0", "@hapi/hoek": "^9.2.0", "@hapi/inert": "^6.0.4", - "@hapi/podium": "^4.1.3", "@hapi/wreck": "^17.1.0", "@kbn/ace": "link:bazel-bin/packages/kbn-ace", "@kbn/alerts": "link:bazel-bin/packages/kbn-alerts", @@ -133,7 +133,6 @@ "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", "@kbn/interpreter": "link:bazel-bin/packages/kbn-interpreter", "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", - "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", "@kbn/logging": "link:bazel-bin/packages/kbn-logging", "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 36bdee5303cb7..75c8d700e2843 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -29,7 +29,6 @@ filegroup( "//packages/kbn-i18n:build", "//packages/kbn-interpreter:build", "//packages/kbn-io-ts-utils:build", - "//packages/kbn-legacy-logging:build", "//packages/kbn-logging:build", "//packages/kbn-mapbox-gl:build", "//packages/kbn-monaco:build", diff --git a/packages/kbn-cli-dev-mode/src/bootstrap.ts b/packages/kbn-cli-dev-mode/src/bootstrap.ts index 86a276c64f1f5..0428051b77e31 100644 --- a/packages/kbn-cli-dev-mode/src/bootstrap.ts +++ b/packages/kbn-cli-dev-mode/src/bootstrap.ts @@ -20,7 +20,7 @@ interface BootstrapArgs { } export async function bootstrapDevMode({ configs, cliArgs, applyConfigOverrides }: BootstrapArgs) { - const log = new CliLog(!!cliArgs.quiet, !!cliArgs.silent); + const log = new CliLog(!!cliArgs.silent); const env = Env.createDefault(REPO_ROOT, { configs, 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 8937eadfa4ee3..e5e009e51e69e 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 @@ -74,7 +74,6 @@ const createCliArgs = (parts: Partial = {}): SomeCliArgs => ({ runExamples: false, watch: true, silent: false, - quiet: false, ...parts, }); 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 28f38592ff3c4..2396b316aa3a2 100644 --- a/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts +++ b/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts @@ -48,7 +48,6 @@ const GRACEFUL_TIMEOUT = 30000; export type SomeCliArgs = Pick< CliArgs, - | 'quiet' | 'silent' | 'verbose' | 'disableOptimizer' @@ -108,7 +107,7 @@ export class CliDevMode { private subscription?: Rx.Subscription; constructor({ cliArgs, config, log }: { cliArgs: SomeCliArgs; config: CliDevConfig; log?: Log }) { - this.log = log || new CliLog(!!cliArgs.quiet, !!cliArgs.silent); + this.log = log || new CliLog(!!cliArgs.silent); if (cliArgs.basePath) { this.basePathProxy = new BasePathProxyServer(this.log, config.http, config.dev); @@ -163,7 +162,7 @@ export class CliDevMode { runExamples: cliArgs.runExamples, cache: cliArgs.cache, dist: cliArgs.dist, - quiet: !!cliArgs.quiet, + quiet: false, silent: !!cliArgs.silent, verbose: !!cliArgs.verbose, watch: cliArgs.watch, diff --git a/packages/kbn-cli-dev-mode/src/dev_server.test.ts b/packages/kbn-cli-dev-mode/src/dev_server.test.ts index 9962a9a285a42..92dbe484eb005 100644 --- a/packages/kbn-cli-dev-mode/src/dev_server.test.ts +++ b/packages/kbn-cli-dev-mode/src/dev_server.test.ts @@ -130,7 +130,6 @@ describe('#run$', () => { Array [ "foo", "bar", - "--logging.json=false", ], Object { "env": Object { diff --git a/packages/kbn-cli-dev-mode/src/log.ts b/packages/kbn-cli-dev-mode/src/log.ts index 86956abec202a..2cbd02b94a844 100644 --- a/packages/kbn-cli-dev-mode/src/log.ts +++ b/packages/kbn-cli-dev-mode/src/log.ts @@ -21,7 +21,7 @@ export interface Log { export class CliLog implements Log { public toolingLog = new ToolingLog({ - level: this.silent ? 'silent' : this.quiet ? 'error' : 'info', + level: this.silent ? 'silent' : 'info', writeTo: { write: (msg) => { this.write(msg); @@ -29,10 +29,10 @@ export class CliLog implements Log { }, }); - constructor(private readonly quiet: boolean, private readonly silent: boolean) {} + constructor(private readonly silent: boolean) {} good(label: string, ...args: any[]) { - if (this.quiet || this.silent) { + if (this.silent) { return; } @@ -41,7 +41,7 @@ export class CliLog implements Log { } warn(label: string, ...args: any[]) { - if (this.quiet || this.silent) { + if (this.silent) { return; } diff --git a/packages/kbn-cli-dev-mode/src/using_server_process.ts b/packages/kbn-cli-dev-mode/src/using_server_process.ts index 0d0227c63adc2..eb997295035d8 100644 --- a/packages/kbn-cli-dev-mode/src/using_server_process.ts +++ b/packages/kbn-cli-dev-mode/src/using_server_process.ts @@ -25,7 +25,7 @@ export function usingServerProcess( ) { return Rx.using( (): ProcResource => { - const proc = execa.node(script, [...argv, '--logging.json=false'], { + const proc = execa.node(script, argv, { stdio: 'pipe', nodeOptions: [ ...process.execArgv, diff --git a/packages/kbn-config/src/__mocks__/env.ts b/packages/kbn-config/src/__mocks__/env.ts index 6f05f8f1f5a45..124a798501a96 100644 --- a/packages/kbn-config/src/__mocks__/env.ts +++ b/packages/kbn-config/src/__mocks__/env.ts @@ -19,7 +19,6 @@ export function getEnvOptions(options: DeepPartial = {}): EnvOptions configs: options.configs || [], cliArgs: { dev: true, - quiet: false, silent: false, watch: false, basePath: false, diff --git a/packages/kbn-config/src/__snapshots__/env.test.ts.snap b/packages/kbn-config/src/__snapshots__/env.test.ts.snap index 570ed948774cc..a8e2eb62dbedb 100644 --- a/packages/kbn-config/src/__snapshots__/env.test.ts.snap +++ b/packages/kbn-config/src/__snapshots__/env.test.ts.snap @@ -11,7 +11,6 @@ Env { "dist": false, "envName": "development", "oss": false, - "quiet": false, "runExamples": false, "silent": false, "watch": false, @@ -54,7 +53,6 @@ Env { "dist": false, "envName": "production", "oss": false, - "quiet": false, "runExamples": false, "silent": false, "watch": false, @@ -96,7 +94,6 @@ Env { "disableOptimizer": true, "dist": false, "oss": false, - "quiet": false, "runExamples": false, "silent": false, "watch": false, @@ -138,7 +135,6 @@ Env { "disableOptimizer": true, "dist": false, "oss": false, - "quiet": false, "runExamples": false, "silent": false, "watch": false, @@ -180,7 +176,6 @@ Env { "disableOptimizer": true, "dist": false, "oss": false, - "quiet": false, "runExamples": false, "silent": false, "watch": false, @@ -222,7 +217,6 @@ Env { "disableOptimizer": true, "dist": false, "oss": false, - "quiet": false, "runExamples": false, "silent": false, "watch": false, diff --git a/packages/kbn-config/src/config_service.test.ts b/packages/kbn-config/src/config_service.test.ts index 754de1c0a99f5..e8fd7ab187596 100644 --- a/packages/kbn-config/src/config_service.test.ts +++ b/packages/kbn-config/src/config_service.test.ts @@ -15,6 +15,7 @@ import { rawConfigServiceMock } from './raw/raw_config_service.mock'; import { schema } from '@kbn/config-schema'; import { MockedLogger, loggerMock } from '@kbn/logging/mocks'; +import type { ConfigDeprecationContext } from './deprecation'; import { ConfigService, Env, RawPackageInfo } from '.'; import { getEnvOptions } from './__mocks__/env'; @@ -475,6 +476,43 @@ test('logs deprecation warning during validation', async () => { `); }); +test('calls `applyDeprecations` with the correct parameters', async () => { + const cfg = { foo: { bar: 1 } }; + const rawConfig = getRawConfigProvider(cfg); + const configService = new ConfigService(rawConfig, defaultEnv, logger); + + const context: ConfigDeprecationContext = { + branch: defaultEnv.packageInfo.branch, + version: defaultEnv.packageInfo.version, + }; + + const deprecationA = jest.fn(); + const deprecationB = jest.fn(); + + configService.addDeprecationProvider('foo', () => [deprecationA]); + configService.addDeprecationProvider('bar', () => [deprecationB]); + + await configService.validate(); + + expect(mockApplyDeprecations).toHaveBeenCalledTimes(1); + expect(mockApplyDeprecations).toHaveBeenCalledWith( + cfg, + [ + { + deprecation: deprecationA, + path: 'foo', + context, + }, + { + deprecation: deprecationB, + path: 'bar', + context, + }, + ], + expect.any(Function) + ); +}); + test('does not log warnings for silent deprecations during validation', async () => { const rawConfig = getRawConfigProvider({}); const configService = new ConfigService(rawConfig, defaultEnv, logger); diff --git a/packages/kbn-config/src/config_service.ts b/packages/kbn-config/src/config_service.ts index 5883ce8ab513c..5103aa1a2d49d 100644 --- a/packages/kbn-config/src/config_service.ts +++ b/packages/kbn-config/src/config_service.ts @@ -19,12 +19,13 @@ import { RawConfigurationProvider } from './raw/raw_config_service'; import { applyDeprecations, ConfigDeprecationWithContext, + ConfigDeprecationContext, ConfigDeprecationProvider, configDeprecationFactory, DeprecatedConfigDetails, ChangedDeprecatedPaths, } from './deprecation'; -import { LegacyObjectToConfigAdapter } from './legacy'; +import { ObjectToConfigAdapter } from './object_to_config_adapter'; /** @internal */ export type IConfigService = PublicMethodsOf; @@ -71,7 +72,7 @@ export class ConfigService { map(([rawConfig, deprecations]) => { const migrated = applyDeprecations(rawConfig, deprecations); this.deprecatedConfigPaths.next(migrated.changedPaths); - return new LegacyObjectToConfigAdapter(migrated.config); + return new ObjectToConfigAdapter(migrated.config); }), tap((config) => { this.lastConfig = config; @@ -103,6 +104,7 @@ export class ConfigService { ...provider(configDeprecationFactory).map((deprecation) => ({ deprecation, path: flatPath, + context: createDeprecationContext(this.env), })), ]); } @@ -298,3 +300,10 @@ const pathToString = (path: ConfigPath) => (Array.isArray(path) ? path.join('.') */ const isPathHandled = (path: string, handledPaths: string[]) => handledPaths.some((handledPath) => hasConfigPathIntersection(path, handledPath)); + +const createDeprecationContext = (env: Env): ConfigDeprecationContext => { + return { + branch: env.packageInfo.branch, + version: env.packageInfo.version, + }; +}; diff --git a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts index 8ad1491c19c9b..70945b2d96b32 100644 --- a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts +++ b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts @@ -7,18 +7,24 @@ */ import { applyDeprecations } from './apply_deprecations'; -import { ConfigDeprecation, ConfigDeprecationWithContext } from './types'; +import { ConfigDeprecation, ConfigDeprecationContext, ConfigDeprecationWithContext } from './types'; import { configDeprecationFactory as deprecations } from './deprecation_factory'; -const wrapHandler = ( - handler: ConfigDeprecation, - path: string = '' -): ConfigDeprecationWithContext => ({ - deprecation: handler, - path, -}); - describe('applyDeprecations', () => { + const context: ConfigDeprecationContext = { + version: '7.16.2', + branch: '7.16', + }; + + const wrapHandler = ( + handler: ConfigDeprecation, + path: string = '' + ): ConfigDeprecationWithContext => ({ + deprecation: handler, + path, + context, + }); + it('calls all deprecations handlers once', () => { const handlerA = jest.fn(); const handlerB = jest.fn(); @@ -32,6 +38,26 @@ describe('applyDeprecations', () => { expect(handlerC).toHaveBeenCalledTimes(1); }); + it('calls deprecations handlers with the correct parameters', () => { + const config = { foo: 'bar' }; + const addDeprecation = jest.fn(); + const createAddDeprecation = jest.fn().mockReturnValue(addDeprecation); + + const handlerA = jest.fn(); + const handlerB = jest.fn(); + applyDeprecations( + config, + [wrapHandler(handlerA, 'pathA'), wrapHandler(handlerB, 'pathB')], + createAddDeprecation + ); + + expect(handlerA).toHaveBeenCalledTimes(1); + expect(handlerA).toHaveBeenCalledWith(config, 'pathA', addDeprecation, context); + + expect(handlerB).toHaveBeenCalledTimes(1); + expect(handlerB).toHaveBeenCalledWith(config, 'pathB', addDeprecation, context); + }); + it('passes path to addDeprecation factory', () => { const addDeprecation = jest.fn(); const createAddDeprecation = jest.fn().mockReturnValue(addDeprecation); @@ -51,7 +77,7 @@ describe('applyDeprecations', () => { expect(createAddDeprecation).toHaveBeenNthCalledWith(2, 'pathB'); }); - it('calls handlers with correct arguments', () => { + it('calls handlers with correct config argument', () => { const addDeprecation = jest.fn(); const createAddDeprecation = jest.fn().mockReturnValue(addDeprecation); const initialConfig = { foo: 'bar', deprecated: 'deprecated' }; diff --git a/packages/kbn-config/src/deprecation/apply_deprecations.ts b/packages/kbn-config/src/deprecation/apply_deprecations.ts index d38ae98835831..11b35840969d0 100644 --- a/packages/kbn-config/src/deprecation/apply_deprecations.ts +++ b/packages/kbn-config/src/deprecation/apply_deprecations.ts @@ -15,6 +15,7 @@ import type { } from './types'; const noopAddDeprecationFactory: () => AddConfigDeprecation = () => () => undefined; + /** * Applies deprecations on given configuration and passes addDeprecation hook. * This hook is used for logging any deprecation warning using provided logger. @@ -32,8 +33,8 @@ export const applyDeprecations = ( set: [], unset: [], }; - deprecations.forEach(({ deprecation, path }) => { - const commands = deprecation(result, path, createAddDeprecation(path)); + deprecations.forEach(({ deprecation, path, context }) => { + const commands = deprecation(result, path, createAddDeprecation(path), context); if (commands) { if (commands.set) { changedPaths.set.push(...commands.set.map((c) => c.path)); diff --git a/packages/kbn-config/src/deprecation/deprecation_factory.test.ts b/packages/kbn-config/src/deprecation/deprecation_factory.test.ts index dfd6b8fac681f..415c8fb9f0610 100644 --- a/packages/kbn-config/src/deprecation/deprecation_factory.test.ts +++ b/packages/kbn-config/src/deprecation/deprecation_factory.test.ts @@ -7,11 +7,13 @@ */ import { DeprecatedConfigDetails } from './types'; +import { configDeprecationsMock } from './deprecations.mock'; import { configDeprecationFactory } from './deprecation_factory'; describe('DeprecationFactory', () => { const { deprecate, deprecateFromRoot, rename, renameFromRoot, unused, unusedFromRoot } = configDeprecationFactory; + const context = configDeprecationsMock.createContext(); const addDeprecation = jest.fn(); @@ -30,7 +32,12 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = deprecate('deprecated', '8.0.0')(rawConfig, 'myplugin', addDeprecation); + const commands = deprecate('deprecated', '8.0.0')( + rawConfig, + 'myplugin', + addDeprecation, + context + ); expect(commands).toBeUndefined(); expect(addDeprecation.mock.calls).toMatchInlineSnapshot(` Array [ @@ -64,7 +71,8 @@ describe('DeprecationFactory', () => { const commands = deprecate('section.deprecated', '8.0.0')( rawConfig, 'myplugin', - addDeprecation + addDeprecation, + context ); expect(commands).toBeUndefined(); expect(addDeprecation.mock.calls).toMatchInlineSnapshot(` @@ -93,7 +101,12 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = deprecate('deprecated', '8.0.0')(rawConfig, 'myplugin', addDeprecation); + const commands = deprecate('deprecated', '8.0.0')( + rawConfig, + 'myplugin', + addDeprecation, + context + ); expect(commands).toBeUndefined(); expect(addDeprecation).toBeCalledTimes(0); }); @@ -113,7 +126,8 @@ describe('DeprecationFactory', () => { const commands = deprecateFromRoot('myplugin.deprecated', '8.0.0')( rawConfig, 'does-not-matter', - addDeprecation + addDeprecation, + context ); expect(commands).toBeUndefined(); expect(addDeprecation.mock.calls).toMatchInlineSnapshot(` @@ -145,7 +159,8 @@ describe('DeprecationFactory', () => { const commands = deprecateFromRoot('myplugin.deprecated', '8.0.0')( rawConfig, 'does-not-matter', - addDeprecation + addDeprecation, + context ); expect(commands).toBeUndefined(); expect(addDeprecation).toBeCalledTimes(0); @@ -163,7 +178,12 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = rename('deprecated', 'renamed')(rawConfig, 'myplugin', addDeprecation); + const commands = rename('deprecated', 'renamed')( + rawConfig, + 'myplugin', + addDeprecation, + context + ); expect(commands).toEqual({ set: [ { @@ -199,7 +219,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = rename('deprecated', 'new')(rawConfig, 'myplugin', addDeprecation); + const commands = rename('deprecated', 'new')(rawConfig, 'myplugin', addDeprecation, context); expect(commands).toBeUndefined(); expect(addDeprecation).toHaveBeenCalledTimes(0); }); @@ -218,7 +238,8 @@ describe('DeprecationFactory', () => { const commands = rename('oldsection.deprecated', 'newsection.renamed')( rawConfig, 'myplugin', - addDeprecation + addDeprecation, + context ); expect(commands).toEqual({ set: [ @@ -252,7 +273,12 @@ describe('DeprecationFactory', () => { renamed: 'renamed', }, }; - const commands = rename('deprecated', 'renamed')(rawConfig, 'myplugin', addDeprecation); + const commands = rename('deprecated', 'renamed')( + rawConfig, + 'myplugin', + addDeprecation, + context + ); expect(commands).toEqual({ unset: [{ path: 'myplugin.deprecated' }], }); @@ -289,7 +315,8 @@ describe('DeprecationFactory', () => { const commands = renameFromRoot('myplugin.deprecated', 'myplugin.renamed')( rawConfig, 'does-not-matter', - addDeprecation + addDeprecation, + context ); expect(commands).toEqual({ set: [ @@ -330,7 +357,8 @@ describe('DeprecationFactory', () => { const commands = renameFromRoot('oldplugin.deprecated', 'newplugin.renamed')( rawConfig, 'does-not-matter', - addDeprecation + addDeprecation, + context ); expect(commands).toEqual({ set: [ @@ -371,7 +399,8 @@ describe('DeprecationFactory', () => { const commands = renameFromRoot('myplugin.deprecated', 'myplugin.new')( rawConfig, 'does-not-matter', - addDeprecation + addDeprecation, + context ); expect(commands).toBeUndefined(); expect(addDeprecation).toBeCalledTimes(0); @@ -387,7 +416,8 @@ describe('DeprecationFactory', () => { const commands = renameFromRoot('myplugin.deprecated', 'myplugin.renamed')( rawConfig, 'does-not-matter', - addDeprecation + addDeprecation, + context ); expect(commands).toEqual({ unset: [{ path: 'myplugin.deprecated' }], @@ -423,7 +453,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = unused('deprecated')(rawConfig, 'myplugin', addDeprecation); + const commands = unused('deprecated')(rawConfig, 'myplugin', addDeprecation, context); expect(commands).toEqual({ unset: [{ path: 'myplugin.deprecated' }], }); @@ -456,7 +486,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = unused('section.deprecated')(rawConfig, 'myplugin', addDeprecation); + const commands = unused('section.deprecated')(rawConfig, 'myplugin', addDeprecation, context); expect(commands).toEqual({ unset: [{ path: 'myplugin.section.deprecated' }], }); @@ -486,7 +516,7 @@ describe('DeprecationFactory', () => { property: 'value', }, }; - const commands = unused('deprecated')(rawConfig, 'myplugin', addDeprecation); + const commands = unused('deprecated')(rawConfig, 'myplugin', addDeprecation, context); expect(commands).toBeUndefined(); expect(addDeprecation).toBeCalledTimes(0); }); @@ -506,7 +536,8 @@ describe('DeprecationFactory', () => { const commands = unusedFromRoot('myplugin.deprecated')( rawConfig, 'does-not-matter', - addDeprecation + addDeprecation, + context ); expect(commands).toEqual({ unset: [{ path: 'myplugin.deprecated' }], @@ -540,7 +571,8 @@ describe('DeprecationFactory', () => { const commands = unusedFromRoot('myplugin.deprecated')( rawConfig, 'does-not-matter', - addDeprecation + addDeprecation, + context ); expect(commands).toBeUndefined(); expect(addDeprecation).toBeCalledTimes(0); diff --git a/packages/kbn-config/src/deprecation/deprecations.mock.ts b/packages/kbn-config/src/deprecation/deprecations.mock.ts new file mode 100644 index 0000000000000..80b65c84b4879 --- /dev/null +++ b/packages/kbn-config/src/deprecation/deprecations.mock.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. + */ + +import type { ConfigDeprecationContext } from './types'; + +const createMockedContext = (): ConfigDeprecationContext => { + return { + branch: 'master', + version: '8.0.0', + }; +}; + +export const configDeprecationsMock = { + createContext: createMockedContext, +}; diff --git a/packages/kbn-config/src/deprecation/index.ts b/packages/kbn-config/src/deprecation/index.ts index ce10bafd9c575..fd06ddb6aaa30 100644 --- a/packages/kbn-config/src/deprecation/index.ts +++ b/packages/kbn-config/src/deprecation/index.ts @@ -9,6 +9,7 @@ export type { ConfigDeprecation, ConfigDeprecationCommand, + ConfigDeprecationContext, ConfigDeprecationWithContext, ConfigDeprecationFactory, AddConfigDeprecation, diff --git a/packages/kbn-config/src/deprecation/types.ts b/packages/kbn-config/src/deprecation/types.ts index 47a31b9e6725a..12b561aa2b1b9 100644 --- a/packages/kbn-config/src/deprecation/types.ts +++ b/packages/kbn-config/src/deprecation/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import type { RecursiveReadonly } from '@kbn/utility-types'; + /** * Config deprecation hook used when invoking a {@link ConfigDeprecation} * @@ -19,9 +20,9 @@ export type AddConfigDeprecation = (details: DeprecatedConfigDetails) => void; * @public */ export interface DeprecatedConfigDetails { - /* The title to be displayed for the deprecation. */ + /** The title to be displayed for the deprecation. */ title?: string; - /* The message to be displayed for the deprecation. */ + /** The message to be displayed for the deprecation. */ message: string; /** * levels: @@ -29,11 +30,11 @@ export interface DeprecatedConfigDetails { * - critical: needs to be addressed before upgrade. */ level?: 'warning' | 'critical'; - /* (optional) set false to prevent the config service from logging the deprecation message. */ + /** (optional) set false to prevent the config service from logging the deprecation message. */ silent?: boolean; - /* (optional) link to the documentation for more details on the deprecation. */ + /** (optional) link to the documentation for more details on the deprecation. */ documentationUrl?: string; - /* corrective action needed to fix this deprecation. */ + /** corrective action needed to fix this deprecation. */ correctiveActions: { /** * Specify a list of manual steps our users need to follow @@ -55,14 +56,27 @@ export interface DeprecatedConfigDetails { * ```typescript * const provider: ConfigDeprecation = (config, path) => ({ unset: [{ key: 'path.to.key' }] }) * ``` - * @internal + * @public */ export type ConfigDeprecation = ( config: RecursiveReadonly>, fromPath: string, - addDeprecation: AddConfigDeprecation + addDeprecation: AddConfigDeprecation, + context: ConfigDeprecationContext ) => void | ConfigDeprecationCommand; +/** + * Deprecation context provided to {@link ConfigDeprecation | config deprecations} + * + * @public + */ +export interface ConfigDeprecationContext { + /** The current Kibana version, e.g `7.16.1`, `8.0.0` */ + version: string; + /** The current Kibana branch, e.g `7.x`, `7.16`, `master` */ + branch: string; +} + /** * List of config paths changed during deprecation. * @@ -137,6 +151,7 @@ export interface ConfigDeprecationFactory { removeBy: string, details?: Partial ): ConfigDeprecation; + /** * Deprecate a configuration property from the root configuration. * Will log a deprecation warning if the deprecatedKey was found. @@ -157,6 +172,7 @@ export interface ConfigDeprecationFactory { removeBy: string, details?: Partial ): ConfigDeprecation; + /** * Rename a configuration property from inside a plugin's configuration path. * Will log a deprecation warning if the oldKey was found and deprecation applied. @@ -174,6 +190,7 @@ export interface ConfigDeprecationFactory { newKey: string, details?: Partial ): ConfigDeprecation; + /** * Rename a configuration property from the root configuration. * Will log a deprecation warning if the oldKey was found and deprecation applied. @@ -194,6 +211,7 @@ export interface ConfigDeprecationFactory { newKey: string, details?: Partial ): ConfigDeprecation; + /** * Remove a configuration property from inside a plugin's configuration path. * Will log a deprecation warning if the unused key was found and deprecation applied. @@ -207,6 +225,7 @@ export interface ConfigDeprecationFactory { * ``` */ unused(unusedKey: string, details?: Partial): ConfigDeprecation; + /** * Remove a configuration property from the root configuration. * Will log a deprecation warning if the unused key was found and deprecation applied. @@ -229,4 +248,5 @@ export interface ConfigDeprecationFactory { export interface ConfigDeprecationWithContext { deprecation: ConfigDeprecation; path: string; + context: ConfigDeprecationContext; } diff --git a/packages/kbn-config/src/env.ts b/packages/kbn-config/src/env.ts index 053bb93ce158c..73f32606c463f 100644 --- a/packages/kbn-config/src/env.ts +++ b/packages/kbn-config/src/env.ts @@ -21,8 +21,6 @@ export interface EnvOptions { export interface CliArgs { dev: boolean; envName?: string; - /** @deprecated */ - quiet?: boolean; silent?: boolean; verbose?: boolean; watch: boolean; diff --git a/packages/kbn-config/src/index.ts b/packages/kbn-config/src/index.ts index 08cf12343f459..0068fc87855b0 100644 --- a/packages/kbn-config/src/index.ts +++ b/packages/kbn-config/src/index.ts @@ -13,6 +13,7 @@ export type { ConfigDeprecationWithContext, ConfigDeprecation, ConfigDeprecationCommand, + ConfigDeprecationContext, ChangedDeprecatedPaths, } from './deprecation'; @@ -30,5 +31,4 @@ export { Config, ConfigPath, isConfigPath, hasConfigPathIntersection } from './c export { ObjectToConfigAdapter } from './object_to_config_adapter'; export { CliArgs, Env, RawPackageInfo } from './env'; export { EnvironmentMode, PackageInfo } from './types'; -export { LegacyObjectToConfigAdapter, LegacyLoggingConfig } from './legacy'; export { getPluginSearchPaths } from './plugins'; diff --git a/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap b/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap deleted file mode 100644 index 17ac75e9f3d9e..0000000000000 --- a/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap +++ /dev/null @@ -1,95 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#get correctly handles silent logging config. 1`] = ` -Object { - "appenders": Object { - "default": Object { - "legacyLoggingConfig": Object { - "silent": true, - }, - "type": "legacy-appender", - }, - }, - "loggers": undefined, - "root": Object { - "level": "off", - }, - "silent": true, -} -`; - -exports[`#get correctly handles verbose file logging config with json format. 1`] = ` -Object { - "appenders": Object { - "default": Object { - "legacyLoggingConfig": Object { - "dest": "/some/path.log", - "json": true, - "verbose": true, - }, - "type": "legacy-appender", - }, - }, - "dest": "/some/path.log", - "json": true, - "loggers": undefined, - "root": Object { - "level": "all", - }, - "verbose": true, -} -`; - -exports[`#getFlattenedPaths returns all paths of the underlying object. 1`] = ` -Array [ - "known", - "knownContainer.sub1", - "knownContainer.sub2", - "legacy.known", -] -`; - -exports[`#set correctly sets values for existing paths. 1`] = ` -Object { - "known": "value", - "knownContainer": Object { - "sub1": "sub-value-1", - "sub2": "sub-value-2", - }, -} -`; - -exports[`#set correctly sets values for paths that do not exist. 1`] = ` -Object { - "unknown": Object { - "sub1": "sub-value-1", - "sub2": "sub-value-2", - }, -} -`; - -exports[`#toRaw returns a deep copy of the underlying raw config object. 1`] = ` -Object { - "known": "foo", - "knownContainer": Object { - "sub1": "bar", - "sub2": "baz", - }, - "legacy": Object { - "known": "baz", - }, -} -`; - -exports[`#toRaw returns a deep copy of the underlying raw config object. 2`] = ` -Object { - "known": "bar", - "knownContainer": Object { - "sub1": "baz", - "sub2": "baz", - }, - "legacy": Object { - "known": "baz", - }, -} -`; diff --git a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.test.ts b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.test.ts deleted file mode 100644 index 47151503e1634..0000000000000 --- a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { LegacyObjectToConfigAdapter } from './legacy_object_to_config_adapter'; - -describe('#get', () => { - test('correctly handles paths that do not exist.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({}); - - expect(configAdapter.get('one')).not.toBeDefined(); - expect(configAdapter.get(['one', 'two'])).not.toBeDefined(); - expect(configAdapter.get(['one.three'])).not.toBeDefined(); - }); - - test('correctly handles paths that do not need to be transformed.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({ - one: 'value-one', - two: { - sub: 'value-two-sub', - }, - container: { - value: 'some', - }, - }); - - expect(configAdapter.get('one')).toEqual('value-one'); - expect(configAdapter.get(['two', 'sub'])).toEqual('value-two-sub'); - expect(configAdapter.get('two.sub')).toEqual('value-two-sub'); - expect(configAdapter.get('container')).toEqual({ value: 'some' }); - }); - - test('correctly handles csp config.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({ - csp: { - rules: ['strict'], - }, - }); - - expect(configAdapter.get('csp')).toMatchInlineSnapshot(` - Object { - "rules": Array [ - "strict", - ], - } - `); - }); - - test('correctly handles silent logging config.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({ - logging: { silent: true }, - }); - - expect(configAdapter.get('logging')).toMatchSnapshot(); - }); - - test('correctly handles verbose file logging config with json format.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({ - logging: { verbose: true, json: true, dest: '/some/path.log' }, - }); - - expect(configAdapter.get('logging')).toMatchSnapshot(); - }); -}); - -describe('#set', () => { - test('correctly sets values for paths that do not exist.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({}); - - configAdapter.set('unknown', 'value'); - configAdapter.set(['unknown', 'sub1'], 'sub-value-1'); - configAdapter.set('unknown.sub2', 'sub-value-2'); - - expect(configAdapter.toRaw()).toMatchSnapshot(); - }); - - test('correctly sets values for existing paths.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({ - known: '', - knownContainer: { - sub1: 'sub-1', - sub2: 'sub-2', - }, - }); - - configAdapter.set('known', 'value'); - configAdapter.set(['knownContainer', 'sub1'], 'sub-value-1'); - configAdapter.set('knownContainer.sub2', 'sub-value-2'); - - expect(configAdapter.toRaw()).toMatchSnapshot(); - }); -}); - -describe('#has', () => { - test('returns false if config is not set', () => { - const configAdapter = new LegacyObjectToConfigAdapter({}); - - expect(configAdapter.has('unknown')).toBe(false); - expect(configAdapter.has(['unknown', 'sub1'])).toBe(false); - expect(configAdapter.has('unknown.sub2')).toBe(false); - }); - - test('returns true if config is set.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({ - known: 'foo', - knownContainer: { - sub1: 'bar', - sub2: 'baz', - }, - }); - - expect(configAdapter.has('known')).toBe(true); - expect(configAdapter.has(['knownContainer', 'sub1'])).toBe(true); - expect(configAdapter.has('knownContainer.sub2')).toBe(true); - }); -}); - -describe('#toRaw', () => { - test('returns a deep copy of the underlying raw config object.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({ - known: 'foo', - knownContainer: { - sub1: 'bar', - sub2: 'baz', - }, - legacy: { known: 'baz' }, - }); - - const firstRawCopy = configAdapter.toRaw(); - - configAdapter.set('known', 'bar'); - configAdapter.set(['knownContainer', 'sub1'], 'baz'); - - const secondRawCopy = configAdapter.toRaw(); - - expect(firstRawCopy).not.toBe(secondRawCopy); - expect(firstRawCopy.knownContainer).not.toBe(secondRawCopy.knownContainer); - - expect(firstRawCopy).toMatchSnapshot(); - expect(secondRawCopy).toMatchSnapshot(); - }); -}); - -describe('#getFlattenedPaths', () => { - test('returns all paths of the underlying object.', () => { - const configAdapter = new LegacyObjectToConfigAdapter({ - known: 'foo', - knownContainer: { - sub1: 'bar', - sub2: 'baz', - }, - legacy: { known: 'baz' }, - }); - - expect(configAdapter.getFlattenedPaths()).toMatchSnapshot(); - }); -}); diff --git a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts deleted file mode 100644 index bc6fd49e2498a..0000000000000 --- a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { ConfigPath } from '../config'; -import { ObjectToConfigAdapter } from '../object_to_config_adapter'; - -/** - * Represents logging config supported by the legacy platform. - */ -export interface LegacyLoggingConfig { - silent?: boolean; - verbose?: boolean; - quiet?: boolean; - dest?: string; - json?: boolean; - events?: Record; -} - -type MixedLoggingConfig = LegacyLoggingConfig & Record; - -/** - * Represents adapter between config provided by legacy platform and `Config` - * supported by the current platform. - * @internal - */ -export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { - private static transformLogging(configValue: MixedLoggingConfig = {}) { - const { appenders, root, loggers, ...legacyLoggingConfig } = configValue; - - const loggingConfig = { - appenders: { - ...appenders, - default: { type: 'legacy-appender', legacyLoggingConfig }, - }, - root: { level: 'info', ...root }, - loggers, - ...legacyLoggingConfig, - }; - - if (configValue.silent) { - loggingConfig.root.level = 'off'; - } else if (configValue.quiet) { - loggingConfig.root.level = 'error'; - } else if (configValue.verbose) { - loggingConfig.root.level = 'all'; - } - - return loggingConfig; - } - - public get(configPath: ConfigPath) { - const configValue = super.get(configPath); - switch (configPath) { - case 'logging': - return LegacyObjectToConfigAdapter.transformLogging(configValue as LegacyLoggingConfig); - default: - return configValue; - } - } -} diff --git a/packages/kbn-config/src/mocks.ts b/packages/kbn-config/src/mocks.ts index 0306b0cc0663e..40df96eb41f08 100644 --- a/packages/kbn-config/src/mocks.ts +++ b/packages/kbn-config/src/mocks.ts @@ -14,4 +14,5 @@ export { configMock } from './config.mock'; export { configServiceMock } from './config_service.mock'; export { rawConfigServiceMock } from './raw/raw_config_service.mock'; +export { configDeprecationsMock } from './deprecation/deprecations.mock'; export { getEnvOptions } from './__mocks__/env'; diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts index 4d6ea646b2ab1..45d31c1eefad9 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts @@ -82,6 +82,7 @@ export class CiStatsReporter { const upstreamBranch = options.upstreamBranch ?? this.getUpstreamBranch(); const kibanaUuid = options.kibanaUuid === undefined ? this.getKibanaUuid() : options.kibanaUuid; let email; + let branch; try { const { stdout } = await execa('git', ['config', 'user.email']); @@ -90,19 +91,33 @@ export class CiStatsReporter { this.log.debug(e.message); } + try { + const { stdout } = await execa('git', ['branch', '--show-current']); + branch = stdout; + } catch (e) { + this.log.debug(e.message); + } + + const memUsage = process.memoryUsage(); const isElasticCommitter = email && email.endsWith('@elastic.co') ? true : false; const defaultMetadata = { + kibanaUuid, + isElasticCommitter, committerHash: email ? crypto.createHash('sha256').update(email).digest('hex').substring(0, 20) : undefined, + email: isElasticCommitter ? email : undefined, + branch: isElasticCommitter ? branch : undefined, cpuCount: Os.cpus()?.length, cpuModel: Os.cpus()[0]?.model, cpuSpeed: Os.cpus()[0]?.speed, - email: isElasticCommitter ? email : undefined, freeMem: Os.freemem(), - isElasticCommitter, - kibanaUuid, + memoryUsageRss: memUsage.rss, + memoryUsageHeapTotal: memUsage.heapTotal, + memoryUsageHeapUsed: memUsage.heapUsed, + memoryUsageExternal: memUsage.external, + memoryUsageArrayBuffers: memUsage.arrayBuffers, nestedTiming: process.env.CI_STATS_NESTED_TIMING ? true : false, osArch: Os.arch(), osPlatform: Os.platform(), diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts index d99217c38b410..9cb05608526eb 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts @@ -8,3 +8,4 @@ export * from './ci_stats_reporter'; export * from './ship_ci_stats_cli'; +export { getTimeReporter } from './report_time'; diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/report_time.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/report_time.ts new file mode 100644 index 0000000000000..d10250a03f091 --- /dev/null +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/report_time.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 { CiStatsReporter, ToolingLog } from '..'; + +export const getTimeReporter = (log: ToolingLog, group: string) => { + const reporter = CiStatsReporter.fromEnv(log); + return async (startTime: number, id: string, meta: Record) => { + await reporter.timings({ + timings: [ + { + group, + id, + ms: Date.now() - startTime, + meta, + }, + ], + }); + }; +}; diff --git a/packages/kbn-es/src/cli_commands/snapshot.js b/packages/kbn-es/src/cli_commands/snapshot.js index 7f5653db72b49..e64dcb7c77318 100644 --- a/packages/kbn-es/src/cli_commands/snapshot.js +++ b/packages/kbn-es/src/cli_commands/snapshot.js @@ -8,6 +8,7 @@ const dedent = require('dedent'); const getopts = require('getopts'); +import { ToolingLog, getTimeReporter } from '@kbn/dev-utils'; const { Cluster } = require('../cluster'); exports.description = 'Downloads and run from a nightly snapshot'; @@ -36,6 +37,13 @@ exports.help = (defaults = {}) => { }; exports.run = async (defaults = {}) => { + const runStartTime = Date.now(); + const log = new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }); + const reportTime = getTimeReporter(log, 'scripts/es snapshot'); + const argv = process.argv.slice(2); const options = getopts(argv, { alias: { @@ -56,12 +64,22 @@ exports.run = async (defaults = {}) => { if (options['download-only']) { await cluster.downloadSnapshot(options); } else { + const installStartTime = Date.now(); const { installPath } = await cluster.installSnapshot(options); if (options.dataArchive) { await cluster.extractDataDirectory(installPath, options.dataArchive); } - await cluster.run(installPath, options); + reportTime(installStartTime, 'installed', { + success: true, + ...options, + }); + + await cluster.run(installPath, { + reportTime, + startTime: runStartTime, + ...options, + }); } }; diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index ac4380da88be0..0866b14f4ade8 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -240,7 +240,7 @@ exports.Cluster = class Cluster { * @return {undefined} */ _exec(installPath, opts = {}) { - const { skipNativeRealmSetup = false, ...options } = opts; + const { skipNativeRealmSetup = false, reportTime = () => {}, startTime, ...options } = opts; if (this._process || this._outcome) { throw new Error('ES has already been started'); @@ -321,10 +321,17 @@ exports.Cluster = class Cluster { await nativeRealm.setPasswords(options); }); + let reportSent = false; // parse and forward es stdout to the log this._process.stdout.on('data', (data) => { const lines = parseEsLog(data.toString()); lines.forEach((line) => { + if (!reportSent && line.message.includes('publish_address')) { + reportSent = true; + reportTime(startTime, 'ready', { + success: true, + }); + } this._log.info(line.formattedMessage); }); }); @@ -341,7 +348,16 @@ exports.Cluster = class Cluster { // JVM exits with 143 on SIGTERM and 130 on SIGINT, dont' treat them as errors if (code > 0 && !(code === 143 || code === 130)) { + reportTime(startTime, 'abort', { + success: true, + error: code, + }); throw createCliError(`ES exited with code ${code}`); + } else { + reportTime(startTime, 'error', { + success: false, + error: `exited with ${code}`, + }); } }); } diff --git a/packages/kbn-legacy-logging/BUILD.bazel b/packages/kbn-legacy-logging/BUILD.bazel deleted file mode 100644 index c4927fe076e15..0000000000000 --- a/packages/kbn-legacy-logging/BUILD.bazel +++ /dev/null @@ -1,107 +0,0 @@ -load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") -load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") -load("//src/dev/bazel:index.bzl", "jsts_transpiler") - -PKG_BASE_NAME = "kbn-legacy-logging" -PKG_REQUIRE_NAME = "@kbn/legacy-logging" - -SOURCE_FILES = glob( - [ - "src/**/*.ts", - ], - exclude = ["**/*.test.*"], -) - -SRCS = SOURCE_FILES - -filegroup( - name = "srcs", - srcs = SRCS, -) - -NPM_MODULE_EXTRA_FILES = [ - "package.json", - "README.md" -] - -RUNTIME_DEPS = [ - "//packages/kbn-config-schema", - "//packages/kbn-utils", - "@npm//@elastic/numeral", - "@npm//@hapi/hapi", - "@npm//@hapi/podium", - "@npm//chokidar", - "@npm//lodash", - "@npm//moment-timezone", - "@npm//query-string", - "@npm//rxjs", - "@npm//tslib", -] - -TYPES_DEPS = [ - "//packages/kbn-config-schema", - "//packages/kbn-utils", - "@npm//@elastic/numeral", - "@npm//@hapi/podium", - "@npm//chokidar", - "@npm//query-string", - "@npm//rxjs", - "@npm//tslib", - "@npm//@types/hapi__hapi", - "@npm//@types/jest", - "@npm//@types/lodash", - "@npm//@types/moment-timezone", - "@npm//@types/node", -] - -jsts_transpiler( - name = "target_node", - srcs = SRCS, - build_pkg_name = package_name(), -) - -ts_config( - name = "tsconfig", - src = "tsconfig.json", - deps = [ - "//:tsconfig.base.json", - "//:tsconfig.bazel.json", - ], -) - -ts_project( - name = "tsc_types", - args = ['--pretty'], - srcs = SRCS, - deps = TYPES_DEPS, - declaration = True, - declaration_map = True, - emit_declaration_only = True, - out_dir = "target_types", - source_map = True, - root_dir = "src", - tsconfig = ":tsconfig", -) - -js_library( - name = PKG_BASE_NAME, - srcs = NPM_MODULE_EXTRA_FILES, - deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], - package_name = PKG_REQUIRE_NAME, - visibility = ["//visibility:public"], -) - -pkg_npm( - name = "npm_module", - deps = [ - ":%s" % PKG_BASE_NAME, - ] -) - -filegroup( - name = "build", - srcs = [ - ":npm_module", - ], - visibility = ["//visibility:public"], -) diff --git a/packages/kbn-legacy-logging/README.md b/packages/kbn-legacy-logging/README.md deleted file mode 100644 index 4c5989fc892dc..0000000000000 --- a/packages/kbn-legacy-logging/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# @kbn/legacy-logging - -This package contains the implementation of the legacy logging -system, based on `@hapi/good` \ No newline at end of file diff --git a/packages/kbn-legacy-logging/package.json b/packages/kbn-legacy-logging/package.json deleted file mode 100644 index 6e846ffc5bfaf..0000000000000 --- a/packages/kbn-legacy-logging/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@kbn/legacy-logging", - "version": "1.0.0", - "private": true, - "license": "SSPL-1.0 OR Elastic License 2.0", - "main": "./target_node/index.js", - "types": "./target_types/index.d.ts" -} diff --git a/packages/kbn-legacy-logging/src/get_logging_config.ts b/packages/kbn-legacy-logging/src/get_logging_config.ts deleted file mode 100644 index f74bc5904e24b..0000000000000 --- a/packages/kbn-legacy-logging/src/get_logging_config.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 _ from 'lodash'; -import { getLogReporter } from './log_reporter'; -import { LegacyLoggingConfig } from './schema'; - -/** - * Returns the `@hapi/good` plugin configuration to be used for the legacy logging - * @param config - */ -export function getLoggingConfiguration(config: LegacyLoggingConfig, opsInterval: number) { - const events = config.events; - - if (config.silent) { - _.defaults(events, {}); - } else if (config.quiet) { - _.defaults(events, { - log: ['listening', 'error', 'fatal'], - request: ['error'], - error: '*', - }); - } else if (config.verbose) { - _.defaults(events, { - error: '*', - log: '*', - // To avoid duplicate logs, we explicitly disable these in verbose - // mode as they are already provided by the new logging config under - // the `http.server.response` and `metrics.ops` contexts. - ops: '!', - request: '!', - response: '!', - }); - } else { - _.defaults(events, { - log: ['info', 'warning', 'error', 'fatal'], - request: ['info', 'warning', 'error', 'fatal'], - error: '*', - }); - } - - const loggerStream = getLogReporter({ - config: { - json: config.json, - dest: config.dest, - timezone: config.timezone, - - // I'm adding the default here because if you add another filter - // using the commandline it will remove authorization. I want users - // to have to explicitly set --logging.filter.authorization=none or - // --logging.filter.cookie=none to have it show up in the logs. - filter: _.defaults(config.filter, { - authorization: 'remove', - cookie: 'remove', - }), - }, - events: _.transform( - events, - function (filtered: Record, val: string, key: string) { - // provide a string compatible way to remove events - if (val !== '!') filtered[key] = val; - }, - {} - ), - }); - - const options = { - ops: { - interval: opsInterval, - }, - includes: { - request: ['headers', 'payload'], - response: ['headers', 'payload'], - }, - reporters: { - logReporter: [loggerStream], - }, - }; - return options; -} diff --git a/packages/kbn-legacy-logging/src/index.ts b/packages/kbn-legacy-logging/src/index.ts deleted file mode 100644 index 670df4e95f337..0000000000000 --- a/packages/kbn-legacy-logging/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { LegacyLoggingConfig, legacyLoggingConfigSchema } from './schema'; -export { attachMetaData } from './metadata'; -export { setupLoggingRotate } from './rotate'; -export { setupLogging, reconfigureLogging } from './setup_logging'; -export { getLoggingConfiguration } from './get_logging_config'; -export { LegacyLoggingServer } from './legacy_logging_server'; diff --git a/packages/kbn-legacy-logging/src/legacy_logging_server.test.ts b/packages/kbn-legacy-logging/src/legacy_logging_server.test.ts deleted file mode 100644 index 40019fc90ff42..0000000000000 --- a/packages/kbn-legacy-logging/src/legacy_logging_server.test.ts +++ /dev/null @@ -1,105 +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. - */ - -jest.mock('./setup_logging'); - -import { LegacyLoggingServer, LogRecord } from './legacy_logging_server'; - -test('correctly forwards log records.', () => { - const loggingServer = new LegacyLoggingServer({ events: {} }); - const onLogMock = jest.fn(); - loggingServer.events.on('log', onLogMock); - - const timestamp = 1554433221100; - const firstLogRecord: LogRecord = { - timestamp: new Date(timestamp), - pid: 5355, - level: { - id: 'info', - value: 5, - }, - context: 'some-context', - message: 'some-message', - }; - - const secondLogRecord: LogRecord = { - timestamp: new Date(timestamp), - pid: 5355, - level: { - id: 'error', - value: 3, - }, - context: 'some-context.sub-context', - message: 'some-message', - meta: { unknown: 2 }, - error: new Error('some-error'), - }; - - const thirdLogRecord: LogRecord = { - timestamp: new Date(timestamp), - pid: 5355, - level: { - id: 'trace', - value: 7, - }, - context: 'some-context.sub-context', - message: 'some-message', - meta: { tags: ['important', 'tags'], unknown: 2 }, - }; - - loggingServer.log(firstLogRecord); - loggingServer.log(secondLogRecord); - loggingServer.log(thirdLogRecord); - - expect(onLogMock).toHaveBeenCalledTimes(3); - - const [[firstCall], [secondCall], [thirdCall]] = onLogMock.mock.calls; - expect(firstCall).toMatchInlineSnapshot(` -Object { - "data": "some-message", - "tags": Array [ - "info", - "some-context", - ], - "timestamp": 1554433221100, -} -`); - - expect(secondCall).toMatchInlineSnapshot(` -Object { - "data": [Error: some-error], - "tags": Array [ - "error", - "some-context", - "sub-context", - ], - "timestamp": 1554433221100, -} -`); - - expect(thirdCall).toMatchInlineSnapshot(` -Object { - "data": Object { - Symbol(log message with metadata): Object { - "message": "some-message", - "metadata": Object { - "unknown": 2, - }, - }, - }, - "tags": Array [ - "debug", - "some-context", - "sub-context", - "important", - "tags", - ], - "timestamp": 1554433221100, -} -`); -}); diff --git a/packages/kbn-legacy-logging/src/legacy_logging_server.ts b/packages/kbn-legacy-logging/src/legacy_logging_server.ts deleted file mode 100644 index f6c42dd1b161f..0000000000000 --- a/packages/kbn-legacy-logging/src/legacy_logging_server.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ServerExtType, Server } from '@hapi/hapi'; -import Podium from '@hapi/podium'; -import { setupLogging } from './setup_logging'; -import { attachMetaData } from './metadata'; -import { legacyLoggingConfigSchema } from './schema'; - -// these LogXXX types are duplicated to avoid a cross dependency with the @kbn/logging package. -// typescript will error if they diverge at some point. -type LogLevelId = 'all' | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'off'; - -interface LogLevel { - id: LogLevelId; - value: number; -} - -export interface LogRecord { - timestamp: Date; - level: LogLevel; - context: string; - message: string; - error?: Error; - meta?: { [name: string]: any }; - pid: number; -} - -const isEmptyObject = (obj: object) => Object.keys(obj).length === 0; - -function getDataToLog(error: Error | undefined, metadata: object, message: string) { - if (error) { - return error; - } - if (!isEmptyObject(metadata)) { - return attachMetaData(message, metadata); - } - return message; -} - -interface PluginRegisterParams { - plugin: { - register: ( - server: LegacyLoggingServer, - options: PluginRegisterParams['options'] - ) => Promise; - }; - options: Record; -} - -/** - * Converts core log level to a one that's known to the legacy platform. - * @param level Log level from the core. - */ -function getLegacyLogLevel(level: LogLevel) { - const logLevel = level.id.toLowerCase(); - if (logLevel === 'warn') { - return 'warning'; - } - - if (logLevel === 'trace') { - return 'debug'; - } - - return logLevel; -} - -/** - * The "legacy" Kibana uses Hapi server + even-better plugin to log, so we should - * use the same approach here to make log records generated by the core to look the - * same as the rest of the records generated by the "legacy" Kibana. But to reduce - * overhead of having full blown Hapi server instance we create our own "light" version. - * @internal - */ -export class LegacyLoggingServer { - public connections = []; - // Emulates Hapi's usage of the podium event bus. - public events: Podium = new Podium(['log', 'request', 'response']); - - private onPostStopCallback?: () => void; - - constructor(legacyLoggingConfig: any) { - // We set `ops.interval` to max allowed number and `ops` filter to value - // that doesn't exist to avoid logging of ops at all, if turned on it will be - // logged by the "legacy" Kibana. - const loggingConfig = legacyLoggingConfigSchema.validate({ - ...legacyLoggingConfig, - events: { - ...legacyLoggingConfig.events, - ops: '__no-ops__', - }, - }); - - setupLogging(this as unknown as Server, loggingConfig, 2147483647); - } - - public register({ plugin: { register }, options }: PluginRegisterParams): Promise { - return register(this, options); - } - - public log({ level, context, message, error, timestamp, meta = {} }: LogRecord) { - const { tags = [], ...metadata } = meta; - - this.events - .emit('log', { - data: getDataToLog(error, metadata, message), - tags: [getLegacyLogLevel(level), ...context.split('.'), ...tags], - timestamp: timestamp.getTime(), - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.error('An unexpected error occurred while writing to the log:', err.stack); - process.exit(1); - }); - } - - public stop() { - // Tell the plugin we're stopping. - if (this.onPostStopCallback !== undefined) { - this.onPostStopCallback(); - } - } - - public ext(eventName: ServerExtType, callback: () => void) { - // method is called by plugin that's being registered. - if (eventName === 'onPostStop') { - this.onPostStopCallback = callback; - } - // We don't care about any others the plugin registers - } - - public expose() { - // method is called by plugin that's being registered. - } -} diff --git a/packages/kbn-legacy-logging/src/log_events.ts b/packages/kbn-legacy-logging/src/log_events.ts deleted file mode 100644 index 193bfbea42ace..0000000000000 --- a/packages/kbn-legacy-logging/src/log_events.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { ResponseObject } from '@hapi/hapi'; -import { EventData, isEventData } from './metadata'; - -export interface BaseEvent { - event: string; - timestamp: number; - pid: number; - tags?: string[]; -} - -export interface ResponseEvent extends BaseEvent { - event: 'response'; - method: 'GET' | 'POST' | 'PUT' | 'DELETE'; - statusCode: number; - path: string; - headers: Record; - responseHeaders: Record; - responsePayload: ResponseObject['source']; - responseTime: string; - query: Record; -} - -export interface OpsEvent extends BaseEvent { - event: 'ops'; - os: { - load: string[]; - }; - proc: Record; - load: string; -} - -export interface ErrorEvent extends BaseEvent { - event: 'error'; - error: Error; - url: string; -} - -export interface UndeclaredErrorEvent extends BaseEvent { - error: Error; -} - -export interface LogEvent extends BaseEvent { - data: EventData; -} - -export interface UnkownEvent extends BaseEvent { - data: string | Record; -} - -export type AnyEvent = - | ResponseEvent - | OpsEvent - | ErrorEvent - | UndeclaredErrorEvent - | LogEvent - | UnkownEvent; - -export const isResponseEvent = (e: AnyEvent): e is ResponseEvent => e.event === 'response'; -export const isOpsEvent = (e: AnyEvent): e is OpsEvent => e.event === 'ops'; -export const isErrorEvent = (e: AnyEvent): e is ErrorEvent => e.event === 'error'; -export const isLogEvent = (e: AnyEvent): e is LogEvent => isEventData((e as LogEvent).data); -export const isUndeclaredErrorEvent = (e: AnyEvent): e is UndeclaredErrorEvent => - (e as any).error instanceof Error; diff --git a/packages/kbn-legacy-logging/src/log_format.ts b/packages/kbn-legacy-logging/src/log_format.ts deleted file mode 100644 index a0eaf023dff19..0000000000000 --- a/packages/kbn-legacy-logging/src/log_format.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import Stream from 'stream'; -import moment from 'moment-timezone'; -import _ from 'lodash'; -import queryString from 'query-string'; -import numeral from '@elastic/numeral'; -import chalk from 'chalk'; -import { inspect } from 'util'; - -import { applyFiltersToKeys, getResponsePayloadBytes } from './utils'; -import { getLogEventData } from './metadata'; -import { LegacyLoggingConfig } from './schema'; -import { - AnyEvent, - ResponseEvent, - isResponseEvent, - isOpsEvent, - isErrorEvent, - isLogEvent, - isUndeclaredErrorEvent, -} from './log_events'; - -export type LogFormatConfig = Pick; - -function serializeError(err: any = {}) { - return { - message: err.message, - name: err.name, - stack: err.stack, - code: err.code, - signal: err.signal, - }; -} - -const levelColor = function (code: number) { - if (code < 299) return chalk.green(String(code)); - if (code < 399) return chalk.yellow(String(code)); - if (code < 499) return chalk.magentaBright(String(code)); - return chalk.red(String(code)); -}; - -export abstract class BaseLogFormat extends Stream.Transform { - constructor(private readonly config: LogFormatConfig) { - super({ - readableObjectMode: false, - writableObjectMode: true, - }); - } - - abstract format(data: Record): string; - - filter(data: Record) { - if (!this.config.filter) { - return data; - } - return applyFiltersToKeys(data, this.config.filter); - } - - _transform(event: AnyEvent, enc: string, next: Stream.TransformCallback) { - const data = this.filter(this.readEvent(event)); - this.push(this.format(data) + '\n'); - next(); - } - - getContentLength({ responsePayload, responseHeaders }: ResponseEvent): number | undefined { - try { - return getResponsePayloadBytes(responsePayload, responseHeaders); - } catch (e) { - // We intentionally swallow any errors as this information is - // only a nicety for logging purposes, and should not cause the - // server to crash if it cannot be determined. - this.push( - this.format({ - type: 'log', - tags: ['warning', 'logging'], - message: `Failed to calculate response payload bytes. [${e}]`, - }) + '\n' - ); - } - } - - extractAndFormatTimestamp(data: Record, format?: string) { - const { timezone } = this.config; - const date = moment(data['@timestamp']); - if (timezone) { - date.tz(timezone); - } - return date.format(format); - } - - readEvent(event: AnyEvent) { - const data: Record = { - type: event.event, - '@timestamp': event.timestamp, - tags: [...(event.tags || [])], - pid: event.pid, - }; - - if (isResponseEvent(event)) { - _.defaults(data, _.pick(event, ['method', 'statusCode'])); - - const source = _.get(event, 'source', {}); - data.req = { - url: event.path, - method: event.method || '', - headers: event.headers, - remoteAddress: source.remoteAddress, - userAgent: source.userAgent, - referer: source.referer, - }; - - data.res = { - statusCode: event.statusCode, - responseTime: event.responseTime, - contentLength: this.getContentLength(event), - }; - - const query = queryString.stringify(event.query, { sort: false }); - if (query) { - data.req.url += '?' + query; - } - - data.message = data.req.method.toUpperCase() + ' '; - data.message += data.req.url; - data.message += ' '; - data.message += levelColor(data.res.statusCode); - data.message += ' '; - data.message += chalk.gray(data.res.responseTime + 'ms'); - if (data.res.contentLength) { - data.message += chalk.gray(' - ' + numeral(data.res.contentLength).format('0.0b')); - } - } else if (isOpsEvent(event)) { - _.defaults(data, _.pick(event, ['pid', 'os', 'proc', 'load'])); - data.message = chalk.gray('memory: '); - data.message += numeral(_.get(data, 'proc.mem.heapUsed')).format('0.0b'); - data.message += ' '; - data.message += chalk.gray('uptime: '); - data.message += numeral(_.get(data, 'proc.uptime')).format('00:00:00'); - data.message += ' '; - data.message += chalk.gray('load: ['); - data.message += _.get(data, 'os.load', []) - .map((val: number) => { - return numeral(val).format('0.00'); - }) - .join(' '); - data.message += chalk.gray(']'); - data.message += ' '; - data.message += chalk.gray('delay: '); - data.message += numeral(_.get(data, 'proc.delay')).format('0.000'); - } else if (isErrorEvent(event)) { - data.level = 'error'; - data.error = serializeError(event.error); - data.url = event.url; - const message = _.get(event, 'error.message'); - data.message = message || 'Unknown error (no message)'; - } else if (isUndeclaredErrorEvent(event)) { - data.type = 'error'; - data.level = _.includes(event.tags, 'fatal') ? 'fatal' : 'error'; - data.error = serializeError(event.error); - const message = _.get(event, 'error.message'); - data.message = message || 'Unknown error object (no message)'; - } else if (isLogEvent(event)) { - _.assign(data, getLogEventData(event.data)); - } else { - data.message = _.isString(event.data) ? event.data : inspect(event.data); - } - return data; - } -} diff --git a/packages/kbn-legacy-logging/src/log_format_json.test.ts b/packages/kbn-legacy-logging/src/log_format_json.test.ts deleted file mode 100644 index 3255c5d17bb30..0000000000000 --- a/packages/kbn-legacy-logging/src/log_format_json.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import moment from 'moment'; - -import { attachMetaData } from './metadata'; -import { createListStream, createPromiseFromStreams } from '@kbn/utils'; -import { KbnLoggerJsonFormat } from './log_format_json'; - -const time = +moment('2010-01-01T05:15:59Z', moment.ISO_8601); - -const makeEvent = (eventType: string) => ({ - event: eventType, - timestamp: time, -}); - -describe('KbnLoggerJsonFormat', () => { - const config: any = {}; - - describe('event types and messages', () => { - let format: KbnLoggerJsonFormat; - beforeEach(() => { - format = new KbnLoggerJsonFormat(config); - }); - - it('log', async () => { - const result = await createPromiseFromStreams([ - createListStream([makeEvent('log')]), - format, - ]); - const { type, message } = JSON.parse(result); - - expect(type).toBe('log'); - expect(message).toBe('undefined'); - }); - - describe('response', () => { - it('handles a response object', async () => { - const event = { - ...makeEvent('response'), - statusCode: 200, - contentLength: 800, - responseTime: 12000, - method: 'GET', - path: '/path/to/resource', - responsePayload: '1234567879890', - source: { - remoteAddress: '127.0.0.1', - userAgent: 'Test Thing', - referer: 'elastic.co', - }, - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { type, method, statusCode, message, req } = JSON.parse(result); - - expect(type).toBe('response'); - expect(method).toBe('GET'); - expect(statusCode).toBe(200); - expect(message).toBe('GET /path/to/resource 200 12000ms - 13.0B'); - expect(req.remoteAddress).toBe('127.0.0.1'); - expect(req.userAgent).toBe('Test Thing'); - }); - - it('leaves payload size empty if not available', async () => { - const event = { - ...makeEvent('response'), - statusCode: 200, - responseTime: 12000, - method: 'GET', - path: '/path/to/resource', - responsePayload: null, - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - expect(JSON.parse(result).message).toBe('GET /path/to/resource 200 12000ms'); - }); - }); - - it('ops', async () => { - const event = { - ...makeEvent('ops'), - os: { - load: [1, 1, 2], - }, - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { type, message } = JSON.parse(result); - - expect(type).toBe('ops'); - expect(message).toBe('memory: 0.0B uptime: 0:00:00 load: [1.00 1.00 2.00] delay: 0.000'); - }); - - describe('with metadata', () => { - it('logs an event with meta data', async () => { - const event = { - data: attachMetaData('message for event', { - prop1: 'value1', - prop2: 'value2', - }), - tags: ['tag1', 'tag2'], - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { level, message, prop1, prop2, tags } = JSON.parse(result); - - expect(level).toBe(undefined); - expect(message).toBe('message for event'); - expect(prop1).toBe('value1'); - expect(prop2).toBe('value2'); - expect(tags).toEqual(['tag1', 'tag2']); - }); - - it('meta data rewrites event fields', async () => { - const event = { - data: attachMetaData('message for event', { - tags: ['meta-data-tag'], - prop1: 'value1', - prop2: 'value2', - }), - tags: ['tag1', 'tag2'], - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { level, message, prop1, prop2, tags } = JSON.parse(result); - - expect(level).toBe(undefined); - expect(message).toBe('message for event'); - expect(prop1).toBe('value1'); - expect(prop2).toBe('value2'); - expect(tags).toEqual(['meta-data-tag']); - }); - - it('logs an event with empty meta data', async () => { - const event = { - data: attachMetaData('message for event'), - tags: ['tag1', 'tag2'], - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { level, message, prop1, prop2, tags } = JSON.parse(result); - - expect(level).toBe(undefined); - expect(message).toBe('message for event'); - expect(prop1).toBe(undefined); - expect(prop2).toBe(undefined); - expect(tags).toEqual(['tag1', 'tag2']); - }); - - it('does not log meta data for an error event', async () => { - const event = { - error: new Error('reason'), - data: attachMetaData('message for event', { - prop1: 'value1', - prop2: 'value2', - }), - tags: ['tag1', 'tag2'], - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { level, message, prop1, prop2, tags } = JSON.parse(result); - - expect(level).toBe('error'); - expect(message).toBe('reason'); - expect(prop1).toBe(undefined); - expect(prop2).toBe(undefined); - expect(tags).toEqual(['tag1', 'tag2']); - }); - }); - - describe('errors', () => { - it('error type', async () => { - const event = { - ...makeEvent('error'), - error: { - message: 'test error 0', - }, - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { level, message, error } = JSON.parse(result); - - expect(level).toBe('error'); - expect(message).toBe('test error 0'); - expect(error).toEqual({ message: 'test error 0' }); - }); - - it('with no message', async () => { - const event = { - event: 'error', - error: {}, - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { level, message, error } = JSON.parse(result); - - expect(level).toBe('error'); - expect(message).toBe('Unknown error (no message)'); - expect(error).toEqual({}); - }); - - it('event error instanceof Error', async () => { - const event = { - error: new Error('test error 2') as any, - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { level, message, error } = JSON.parse(result); - - expect(level).toBe('error'); - expect(message).toBe('test error 2'); - - expect(error.message).toBe(event.error.message); - expect(error.name).toBe(event.error.name); - expect(error.stack).toBe(event.error.stack); - expect(error.code).toBe(event.error.code); - expect(error.signal).toBe(event.error.signal); - }); - - it('event error instanceof Error - fatal', async () => { - const event = { - error: new Error('test error 2') as any, - tags: ['fatal', 'tag2'], - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { tags, level, message, error } = JSON.parse(result); - - expect(tags).toEqual(['fatal', 'tag2']); - expect(level).toBe('fatal'); - expect(message).toBe('test error 2'); - - expect(error.message).toBe(event.error.message); - expect(error.name).toBe(event.error.name); - expect(error.stack).toBe(event.error.stack); - expect(error.code).toBe(event.error.code); - expect(error.signal).toBe(event.error.signal); - }); - - it('event error instanceof Error, no message', async () => { - const event = { - error: new Error('') as any, - }; - const result = await createPromiseFromStreams([createListStream([event]), format]); - const { level, message, error } = JSON.parse(result); - - expect(level).toBe('error'); - expect(message).toBe('Unknown error object (no message)'); - - expect(error.message).toBe(event.error.message); - expect(error.name).toBe(event.error.name); - expect(error.stack).toBe(event.error.stack); - expect(error.code).toBe(event.error.code); - expect(error.signal).toBe(event.error.signal); - }); - }); - }); - - describe('timezone', () => { - it('logs in UTC', async () => { - const format = new KbnLoggerJsonFormat({ - timezone: 'UTC', - } as any); - - const result = await createPromiseFromStreams([ - createListStream([makeEvent('log')]), - format, - ]); - - const { '@timestamp': timestamp } = JSON.parse(result); - expect(timestamp).toBe(moment.utc(time).format()); - }); - - it('logs in local timezone timezone is undefined', async () => { - const format = new KbnLoggerJsonFormat({} as any); - - const result = await createPromiseFromStreams([ - createListStream([makeEvent('log')]), - format, - ]); - - const { '@timestamp': timestamp } = JSON.parse(result); - expect(timestamp).toBe(moment(time).format()); - }); - }); -}); diff --git a/packages/kbn-legacy-logging/src/log_format_json.ts b/packages/kbn-legacy-logging/src/log_format_json.ts deleted file mode 100644 index 427415d1715a6..0000000000000 --- a/packages/kbn-legacy-logging/src/log_format_json.ts +++ /dev/null @@ -1,23 +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. - */ - -// @ts-expect-error missing type def -import stringify from 'json-stringify-safe'; -import { BaseLogFormat } from './log_format'; - -const stripColors = function (string: string) { - return string.replace(/\u001b[^m]+m/g, ''); -}; - -export class KbnLoggerJsonFormat extends BaseLogFormat { - format(data: Record) { - data.message = stripColors(data.message); - data['@timestamp'] = this.extractAndFormatTimestamp(data); - return stringify(data); - } -} diff --git a/packages/kbn-legacy-logging/src/log_format_string.test.ts b/packages/kbn-legacy-logging/src/log_format_string.test.ts deleted file mode 100644 index 3ea02c2cfb286..0000000000000 --- a/packages/kbn-legacy-logging/src/log_format_string.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import moment from 'moment'; - -import { attachMetaData } from './metadata'; -import { createListStream, createPromiseFromStreams } from '@kbn/utils'; -import { KbnLoggerStringFormat } from './log_format_string'; - -const time = +moment('2010-01-01T05:15:59Z', moment.ISO_8601); - -const makeEvent = () => ({ - event: 'log', - timestamp: time, - tags: ['tag'], - pid: 1, - data: 'my log message', -}); - -describe('KbnLoggerStringFormat', () => { - it('logs in UTC', async () => { - const format = new KbnLoggerStringFormat({ - timezone: 'UTC', - } as any); - - const result = await createPromiseFromStreams([createListStream([makeEvent()]), format]); - - expect(String(result)).toContain(moment.utc(time).format('HH:mm:ss.SSS')); - }); - - it('logs in local timezone when timezone is undefined', async () => { - const format = new KbnLoggerStringFormat({} as any); - - const result = await createPromiseFromStreams([createListStream([makeEvent()]), format]); - - expect(String(result)).toContain(moment(time).format('HH:mm:ss.SSS')); - }); - describe('with metadata', () => { - it('does not log meta data', async () => { - const format = new KbnLoggerStringFormat({} as any); - const event = { - data: attachMetaData('message for event', { - prop1: 'value1', - }), - tags: ['tag1', 'tag2'], - }; - - const result = await createPromiseFromStreams([createListStream([event]), format]); - - const resultString = String(result); - expect(resultString).toContain('tag1'); - expect(resultString).toContain('tag2'); - expect(resultString).toContain('message for event'); - - expect(resultString).not.toContain('value1'); - expect(resultString).not.toContain('prop1'); - }); - }); -}); diff --git a/packages/kbn-legacy-logging/src/log_format_string.ts b/packages/kbn-legacy-logging/src/log_format_string.ts deleted file mode 100644 index da21e56e00340..0000000000000 --- a/packages/kbn-legacy-logging/src/log_format_string.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 _ from 'lodash'; -import chalk from 'chalk'; - -import { BaseLogFormat } from './log_format'; - -const statuses = ['err', 'info', 'error', 'warning', 'fatal', 'status', 'debug']; - -const typeColors: Record = { - log: 'white', - req: 'green', - res: 'green', - ops: 'cyan', - config: 'cyan', - err: 'red', - info: 'green', - error: 'red', - warning: 'red', - fatal: 'magentaBright', - status: 'yellowBright', - debug: 'gray', - server: 'gray', - optmzr: 'white', - manager: 'green', - optimize: 'magentaBright', - listening: 'magentaBright', - scss: 'magentaBright', -}; - -const color = _.memoize((name: string): ((...text: string[]) => string) => { - // @ts-expect-error couldn't even get rid of the error with an any cast - return chalk[typeColors[name]] || _.identity; -}); - -const type = _.memoize((t: string) => { - return color(t)(_.pad(t, 7).slice(0, 7)); -}); - -const prefix = process.env.isDevCliChild ? `${type('server')} ` : ''; - -export class KbnLoggerStringFormat extends BaseLogFormat { - format(data: Record) { - const time = color('time')(this.extractAndFormatTimestamp(data, 'HH:mm:ss.SSS')); - const msg = data.error ? color('error')(data.error.stack) : color('message')(data.message); - - const tags = _(data.tags) - .sortBy(function (tag) { - if (color(tag) === _.identity) return `2${tag}`; - if (_.includes(statuses, tag)) return `0${tag}`; - return `1${tag}`; - }) - .reduce(function (s, t) { - return s + `[${color(t)(t)}]`; - }, ''); - - return `${prefix}${type(data.type)} [${time}] ${tags} ${msg}`; - } -} diff --git a/packages/kbn-legacy-logging/src/log_interceptor.test.ts b/packages/kbn-legacy-logging/src/log_interceptor.test.ts deleted file mode 100644 index 53d622444ece8..0000000000000 --- a/packages/kbn-legacy-logging/src/log_interceptor.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ErrorEvent } from './log_events'; -import { LogInterceptor } from './log_interceptor'; - -function stubClientErrorEvent(errorMeta: Record): ErrorEvent { - const error = new Error(); - Object.assign(error, errorMeta); - return { - event: 'error', - url: '', - pid: 1234, - timestamp: Date.now(), - tags: ['connection', 'client', 'error'], - error, - }; -} - -const stubEconnresetEvent = () => stubClientErrorEvent({ code: 'ECONNRESET' }); -const stubEpipeEvent = () => stubClientErrorEvent({ errno: 'EPIPE' }); -const stubEcanceledEvent = () => stubClientErrorEvent({ errno: 'ECANCELED' }); - -function assertDowngraded(transformed: Record) { - expect(!!transformed).toBe(true); - expect(transformed).toHaveProperty('event', 'log'); - expect(transformed).toHaveProperty('tags'); - expect(transformed.tags).not.toContain('error'); -} - -describe('server logging LogInterceptor', () => { - describe('#downgradeIfEconnreset()', () => { - it('transforms ECONNRESET events', () => { - const interceptor = new LogInterceptor(); - const event = stubEconnresetEvent(); - assertDowngraded(interceptor.downgradeIfEconnreset(event)!); - }); - - it('does not match if the tags are not in order', () => { - const interceptor = new LogInterceptor(); - const event = stubEconnresetEvent(); - event.tags = [...event.tags!.slice(1), event.tags![0]]; - expect(interceptor.downgradeIfEconnreset(event)).toBe(null); - }); - - it('ignores non ECONNRESET events', () => { - const interceptor = new LogInterceptor(); - const event = stubClientErrorEvent({ errno: 'not ECONNRESET' }); - expect(interceptor.downgradeIfEconnreset(event)).toBe(null); - }); - - it('ignores if tags are wrong', () => { - const interceptor = new LogInterceptor(); - const event = stubEconnresetEvent(); - event.tags = ['different', 'tags']; - expect(interceptor.downgradeIfEconnreset(event)).toBe(null); - }); - }); - - describe('#downgradeIfEpipe()', () => { - it('transforms EPIPE events', () => { - const interceptor = new LogInterceptor(); - const event = stubEpipeEvent(); - assertDowngraded(interceptor.downgradeIfEpipe(event)!); - }); - - it('does not match if the tags are not in order', () => { - const interceptor = new LogInterceptor(); - const event = stubEpipeEvent(); - event.tags = [...event.tags!.slice(1), event.tags![0]]; - expect(interceptor.downgradeIfEpipe(event)).toBe(null); - }); - - it('ignores non EPIPE events', () => { - const interceptor = new LogInterceptor(); - const event = stubClientErrorEvent({ errno: 'not EPIPE' }); - expect(interceptor.downgradeIfEpipe(event)).toBe(null); - }); - - it('ignores if tags are wrong', () => { - const interceptor = new LogInterceptor(); - const event = stubEpipeEvent(); - event.tags = ['different', 'tags']; - expect(interceptor.downgradeIfEpipe(event)).toBe(null); - }); - }); - - describe('#downgradeIfEcanceled()', () => { - it('transforms ECANCELED events', () => { - const interceptor = new LogInterceptor(); - const event = stubEcanceledEvent(); - assertDowngraded(interceptor.downgradeIfEcanceled(event)!); - }); - - it('does not match if the tags are not in order', () => { - const interceptor = new LogInterceptor(); - const event = stubEcanceledEvent(); - event.tags = [...event.tags!.slice(1), event.tags![0]]; - expect(interceptor.downgradeIfEcanceled(event)).toBe(null); - }); - - it('ignores non ECANCELED events', () => { - const interceptor = new LogInterceptor(); - const event = stubClientErrorEvent({ errno: 'not ECANCELLED' }); - expect(interceptor.downgradeIfEcanceled(event)).toBe(null); - }); - - it('ignores if tags are wrong', () => { - const interceptor = new LogInterceptor(); - const event = stubEcanceledEvent(); - event.tags = ['different', 'tags']; - expect(interceptor.downgradeIfEcanceled(event)).toBe(null); - }); - }); - - describe('#downgradeIfHTTPSWhenHTTP', () => { - it('transforms https requests when serving http errors', () => { - const interceptor = new LogInterceptor(); - const event = stubClientErrorEvent({ message: 'Parse Error', code: 'HPE_INVALID_METHOD' }); - assertDowngraded(interceptor.downgradeIfHTTPSWhenHTTP(event)!); - }); - - it('ignores non events', () => { - const interceptor = new LogInterceptor(); - const event = stubClientErrorEvent({ - message: 'Parse Error', - code: 'NOT_HPE_INVALID_METHOD', - }); - expect(interceptor.downgradeIfEcanceled(event)).toBe(null); - }); - }); - - describe('#downgradeIfHTTPWhenHTTPS', () => { - it('transforms http requests when serving https errors', () => { - const message = - '4584650176:error:1408F09C:SSL routines:ssl3_get_record:http request:../deps/openssl/openssl/ssl/record/ssl3_record.c:322:\n'; - const interceptor = new LogInterceptor(); - const event = stubClientErrorEvent({ message }); - assertDowngraded(interceptor.downgradeIfHTTPWhenHTTPS(event)!); - }); - - it('ignores non events', () => { - const interceptor = new LogInterceptor(); - const event = stubClientErrorEvent({ message: 'Not error' }); - expect(interceptor.downgradeIfEcanceled(event)).toBe(null); - }); - }); -}); diff --git a/packages/kbn-legacy-logging/src/log_interceptor.ts b/packages/kbn-legacy-logging/src/log_interceptor.ts deleted file mode 100644 index 1085806135ca6..0000000000000 --- a/packages/kbn-legacy-logging/src/log_interceptor.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import Stream from 'stream'; -import { get, isEqual } from 'lodash'; -import { AnyEvent } from './log_events'; - -/** - * Matches error messages when clients connect via HTTP instead of HTTPS; see unit test for full message. Warning: this can change when Node - * and its bundled OpenSSL binary are upgraded. - */ -const OPENSSL_GET_RECORD_REGEX = /ssl3_get_record:http/; - -function doTagsMatch(event: AnyEvent, tags: string[]) { - return isEqual(event.tags, tags); -} - -function doesMessageMatch(errorMessage: string, match: RegExp | string) { - if (!errorMessage) { - return false; - } - if (match instanceof RegExp) { - return match.test(errorMessage); - } - return errorMessage === match; -} - -// converts the given event into a debug log if it's an error of the given type -function downgradeIfErrorType(errorType: string, event: AnyEvent) { - const isClientError = doTagsMatch(event, ['connection', 'client', 'error']); - if (!isClientError) { - return null; - } - - const matchesErrorType = - get(event, 'error.code') === errorType || get(event, 'error.errno') === errorType; - if (!matchesErrorType) { - return null; - } - - const errorTypeTag = errorType.toLowerCase(); - - return { - event: 'log', - pid: event.pid, - timestamp: event.timestamp, - tags: ['debug', 'connection', errorTypeTag], - data: `${errorType}: Socket was closed by the client (probably the browser) before it could be read completely`, - }; -} - -function downgradeIfErrorMessage(match: RegExp | string, event: AnyEvent) { - const isClientError = doTagsMatch(event, ['connection', 'client', 'error']); - const errorMessage = get(event, 'error.message'); - const matchesErrorMessage = isClientError && doesMessageMatch(errorMessage, match); - - if (!matchesErrorMessage) { - return null; - } - - return { - event: 'log', - pid: event.pid, - timestamp: event.timestamp, - tags: ['debug', 'connection'], - data: errorMessage, - }; -} - -export class LogInterceptor extends Stream.Transform { - constructor() { - super({ - readableObjectMode: true, - writableObjectMode: true, - }); - } - - /** - * Since the upgrade to hapi 14, any socket read - * error is surfaced as a generic "client error" - * but "ECONNRESET" specifically is not useful for the - * logs unless you are trying to debug edge-case behaviors. - * - * For that reason, we downgrade this from error to debug level - * - * @param {object} - log event - */ - downgradeIfEconnreset(event: AnyEvent) { - return downgradeIfErrorType('ECONNRESET', event); - } - - /** - * Since the upgrade to hapi 14, any socket write - * error is surfaced as a generic "client error" - * but "EPIPE" specifically is not useful for the - * logs unless you are trying to debug edge-case behaviors. - * - * For that reason, we downgrade this from error to debug level - * - * @param {object} - log event - */ - downgradeIfEpipe(event: AnyEvent) { - return downgradeIfErrorType('EPIPE', event); - } - - /** - * Since the upgrade to hapi 14, any socket write - * error is surfaced as a generic "client error" - * but "ECANCELED" specifically is not useful for the - * logs unless you are trying to debug edge-case behaviors. - * - * For that reason, we downgrade this from error to debug level - * - * @param {object} - log event - */ - downgradeIfEcanceled(event: AnyEvent) { - return downgradeIfErrorType('ECANCELED', event); - } - - downgradeIfHTTPSWhenHTTP(event: AnyEvent) { - return downgradeIfErrorType('HPE_INVALID_METHOD', event); - } - - downgradeIfHTTPWhenHTTPS(event: AnyEvent) { - return downgradeIfErrorMessage(OPENSSL_GET_RECORD_REGEX, event); - } - - _transform(event: AnyEvent, enc: string, next: Stream.TransformCallback) { - const downgraded = - this.downgradeIfEconnreset(event) || - this.downgradeIfEpipe(event) || - this.downgradeIfEcanceled(event) || - this.downgradeIfHTTPSWhenHTTP(event) || - this.downgradeIfHTTPWhenHTTPS(event); - - this.push(downgraded || event); - next(); - } -} diff --git a/packages/kbn-legacy-logging/src/log_reporter.test.ts b/packages/kbn-legacy-logging/src/log_reporter.test.ts deleted file mode 100644 index a2ad8984ba244..0000000000000 --- a/packages/kbn-legacy-logging/src/log_reporter.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import os from 'os'; -import path from 'path'; -import fs from 'fs'; - -import stripAnsi from 'strip-ansi'; - -import { getLogReporter } from './log_reporter'; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -describe('getLogReporter', () => { - it('should log to stdout (not json)', async () => { - const lines: string[] = []; - const origWrite = process.stdout.write; - process.stdout.write = (buffer: string | Uint8Array): boolean => { - lines.push(stripAnsi(buffer.toString()).trim()); - return true; - }; - - const loggerStream = getLogReporter({ - config: { - json: false, - dest: 'stdout', - filter: {}, - }, - events: { log: '*' }, - }); - - loggerStream.end({ event: 'log', tags: ['foo'], data: 'hello world' }); - - await sleep(500); - - process.stdout.write = origWrite; - expect(lines.length).toBe(1); - expect(lines[0]).toMatch(/^log \[[^\]]*\] \[foo\] hello world$/); - }); - - it('should log to stdout (as json)', async () => { - const lines: string[] = []; - const origWrite = process.stdout.write; - process.stdout.write = (buffer: string | Uint8Array): boolean => { - lines.push(JSON.parse(buffer.toString().trim())); - return true; - }; - - const loggerStream = getLogReporter({ - config: { - json: true, - dest: 'stdout', - filter: {}, - }, - events: { log: '*' }, - }); - - loggerStream.end({ event: 'log', tags: ['foo'], data: 'hello world' }); - - await sleep(500); - - process.stdout.write = origWrite; - expect(lines.length).toBe(1); - expect(lines[0]).toMatchObject({ - type: 'log', - tags: ['foo'], - message: 'hello world', - }); - }); - - it('should log to custom file (not json)', async () => { - const dir = os.tmpdir(); - const logfile = `dest-${Date.now()}.log`; - const dest = path.join(dir, logfile); - - const loggerStream = getLogReporter({ - config: { - json: false, - dest, - filter: {}, - }, - events: { log: '*' }, - }); - - loggerStream.end({ event: 'log', tags: ['foo'], data: 'hello world' }); - - await sleep(500); - - const lines = stripAnsi(fs.readFileSync(dest, { encoding: 'utf8' })) - .trim() - .split(os.EOL); - expect(lines.length).toBe(1); - expect(lines[0]).toMatch(/^log \[[^\]]*\] \[foo\] hello world$/); - }); - - it('should log to custom file (as json)', async () => { - const dir = os.tmpdir(); - const logfile = `dest-${Date.now()}.log`; - const dest = path.join(dir, logfile); - - const loggerStream = getLogReporter({ - config: { - json: true, - dest, - filter: {}, - }, - events: { log: '*' }, - }); - - loggerStream.end({ event: 'log', tags: ['foo'], data: 'hello world' }); - - await sleep(500); - - const lines = fs - .readFileSync(dest, { encoding: 'utf8' }) - .trim() - .split(os.EOL) - .map((data) => JSON.parse(data)); - expect(lines.length).toBe(1); - expect(lines[0]).toMatchObject({ - type: 'log', - tags: ['foo'], - message: 'hello world', - }); - }); -}); diff --git a/packages/kbn-legacy-logging/src/log_reporter.ts b/packages/kbn-legacy-logging/src/log_reporter.ts deleted file mode 100644 index d42fb78f1647b..0000000000000 --- a/packages/kbn-legacy-logging/src/log_reporter.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { createWriteStream } from 'fs'; -import { pipeline } from 'stream'; - -// @ts-expect-error missing type def -import { Squeeze } from '@hapi/good-squeeze'; - -import { KbnLoggerJsonFormat } from './log_format_json'; -import { KbnLoggerStringFormat } from './log_format_string'; -import { LogInterceptor } from './log_interceptor'; -import { LogFormatConfig } from './log_format'; - -export function getLogReporter({ events, config }: { events: any; config: LogFormatConfig }) { - const squeeze = new Squeeze(events); - const format = config.json ? new KbnLoggerJsonFormat(config) : new KbnLoggerStringFormat(config); - const logInterceptor = new LogInterceptor(); - - if (config.dest === 'stdout') { - pipeline(logInterceptor, squeeze, format, onFinished); - // The `pipeline` function is used to properly close all streams in the - // pipeline in case one of them ends or fails. Since stdout obviously - // shouldn't be closed in case of a failure in one of the other streams, - // we're not including that in the call to `pipeline`, but rely on the old - // `pipe` function instead. - format.pipe(process.stdout); - } else { - const dest = createWriteStream(config.dest, { - flags: 'a', - encoding: 'utf8', - }); - pipeline(logInterceptor, squeeze, format, dest, onFinished); - } - - return logInterceptor; -} - -function onFinished(err: NodeJS.ErrnoException | null) { - if (err) { - // eslint-disable-next-line no-console - console.error('An unexpected error occurred in the logging pipeline:', err.stack); - } -} diff --git a/packages/kbn-legacy-logging/src/metadata.ts b/packages/kbn-legacy-logging/src/metadata.ts deleted file mode 100644 index 0f41673ef6723..0000000000000 --- a/packages/kbn-legacy-logging/src/metadata.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { isPlainObject } from 'lodash'; - -export const metadataSymbol = Symbol('log message with metadata'); - -export interface EventData { - [metadataSymbol]?: EventMetadata; - [key: string]: any; -} - -export interface EventMetadata { - message: string; - metadata: Record; -} - -export const isEventData = (eventData: EventData) => { - return Boolean(isPlainObject(eventData) && eventData[metadataSymbol]); -}; - -export const getLogEventData = (eventData: EventData) => { - const { message, metadata } = eventData[metadataSymbol]!; - return { - ...metadata, - message, - }; -}; - -export const attachMetaData = (message: string, metadata: Record = {}) => { - return { - [metadataSymbol]: { - message, - metadata, - }, - }; -}; diff --git a/packages/kbn-legacy-logging/src/rotate/index.ts b/packages/kbn-legacy-logging/src/rotate/index.ts deleted file mode 100644 index 39305dcccf788..0000000000000 --- a/packages/kbn-legacy-logging/src/rotate/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { Server } from '@hapi/hapi'; -import { LogRotator } from './log_rotator'; -import { LegacyLoggingConfig } from '../schema'; - -let logRotator: LogRotator; - -export async function setupLoggingRotate(server: Server, config: LegacyLoggingConfig) { - // If log rotate is not enabled we skip - if (!config.rotate.enabled) { - return; - } - - // We don't want to run logging rotate server if - // we are not logging to a file - if (config.dest === 'stdout') { - server.log( - ['warning', 'logging:rotate'], - 'Log rotation is enabled but logging.dest is configured for stdout. Set logging.dest to a file for this setting to take effect.' - ); - return; - } - - // Enable Logging Rotate Service - // We need the master process and it can - // try to setupLoggingRotate more than once, - // so we'll need to assure it only loads once. - if (!logRotator) { - logRotator = new LogRotator(config, server); - await logRotator.start(); - } - - return logRotator; -} diff --git a/packages/kbn-legacy-logging/src/rotate/log_rotator.test.ts b/packages/kbn-legacy-logging/src/rotate/log_rotator.test.ts deleted file mode 100644 index ce9a24e63455f..0000000000000 --- a/packages/kbn-legacy-logging/src/rotate/log_rotator.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import del from 'del'; -import fs, { existsSync, mkdirSync, statSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { dirname, join } from 'path'; -import { LogRotator } from './log_rotator'; -import { LegacyLoggingConfig } from '../schema'; - -const mockOn = jest.fn(); -jest.mock('chokidar', () => ({ - watch: jest.fn(() => ({ - on: mockOn, - close: jest.fn(), - })), -})); - -jest.mock('lodash', () => ({ - ...(jest.requireActual('lodash') as any), - throttle: (fn: any) => fn, -})); - -const tempDir = join(tmpdir(), 'kbn_log_rotator_test'); -const testFilePath = join(tempDir, 'log_rotator_test_log_file.log'); - -const createLogRotatorConfig = (logFilePath: string): LegacyLoggingConfig => { - return { - dest: logFilePath, - rotate: { - enabled: true, - keepFiles: 2, - everyBytes: 2, - usePolling: false, - pollingInterval: 10000, - pollingPolicyTestTimeout: 4000, - }, - } as LegacyLoggingConfig; -}; - -const mockServer: any = { - log: jest.fn(), -}; - -const writeBytesToFile = (filePath: string, numberOfBytes: number) => { - writeFileSync(filePath, 'a'.repeat(numberOfBytes), { flag: 'a' }); -}; - -describe('LogRotator', () => { - beforeEach(() => { - mkdirSync(tempDir, { recursive: true }); - writeFileSync(testFilePath, ''); - }); - - afterEach(() => { - del.sync(tempDir, { force: true }); - mockOn.mockClear(); - }); - - it('rotates log file when bigger than set limit on start', async () => { - writeBytesToFile(testFilePath, 3); - - const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); - jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); - - await logRotator.start(); - - expect(logRotator.running).toBe(true); - - await logRotator.stop(); - - expect(existsSync(join(tempDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); - }); - - it('rotates log file when equal than set limit over time', async () => { - writeBytesToFile(testFilePath, 1); - - const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); - jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); - await logRotator.start(); - - expect(logRotator.running).toBe(true); - - const testLogFileDir = dirname(testFilePath); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeFalsy(); - - writeBytesToFile(testFilePath, 1); - - // ['change', [asyncFunction]] - const onChangeCb = mockOn.mock.calls[0][1]; - await onChangeCb(testLogFileDir, { size: 2 }); - - await logRotator.stop(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); - }); - - it('rotates log file when file size is bigger than limit', async () => { - writeBytesToFile(testFilePath, 1); - - const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); - jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); - await logRotator.start(); - - expect(logRotator.running).toBe(true); - - const testLogFileDir = dirname(testFilePath); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeFalsy(); - - writeBytesToFile(testFilePath, 2); - - // ['change', [asyncFunction]] - const onChangeCb = mockOn.mock.calls[0][1]; - await onChangeCb(testLogFileDir, { size: 3 }); - - await logRotator.stop(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); - }); - - it('rotates log file service correctly keeps number of files', async () => { - writeBytesToFile(testFilePath, 3); - - const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); - jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); - await logRotator.start(); - - expect(logRotator.running).toBe(true); - - const testLogFileDir = dirname(testFilePath); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); - - writeBytesToFile(testFilePath, 2); - - // ['change', [asyncFunction]] - const onChangeCb = mockOn.mock.calls[0][1]; - await onChangeCb(testLogFileDir, { size: 2 }); - - writeBytesToFile(testFilePath, 5); - await onChangeCb(testLogFileDir, { size: 5 }); - - await logRotator.stop(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.1'))).toBeTruthy(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.2'))).toBeFalsy(); - expect(statSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0')).size).toBe(5); - }); - - it('rotates log file service correctly keeps number of files even when number setting changes', async () => { - writeBytesToFile(testFilePath, 3); - - const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); - jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); - await logRotator.start(); - - expect(logRotator.running).toBe(true); - - const testLogFileDir = dirname(testFilePath); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); - - writeBytesToFile(testFilePath, 2); - - // ['change', [asyncFunction]] - const onChangeCb = mockOn.mock.calls[0][1]; - await onChangeCb(testLogFileDir, { size: 2 }); - - writeBytesToFile(testFilePath, 5); - await onChangeCb(testLogFileDir, { size: 5 }); - - await logRotator.stop(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.1'))).toBeTruthy(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.2'))).toBeFalsy(); - expect(statSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0')).size).toBe(5); - - logRotator.keepFiles = 1; - await logRotator.start(); - - writeBytesToFile(testFilePath, 5); - await onChangeCb(testLogFileDir, { size: 5 }); - - await logRotator.stop(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.1'))).toBeFalsy(); - expect(statSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0')).size).toBe(5); - }); - - it('rotates log file service correctly detects usePolling when it should be false', async () => { - writeBytesToFile(testFilePath, 1); - - const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); - jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); - await logRotator.start(); - - expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(false); - - const shouldUsePolling = await logRotator._shouldUsePolling(); - expect(shouldUsePolling).toBe(false); - - await logRotator.stop(); - }); - - it('rotates log file service correctly detects usePolling when it should be true', async () => { - writeBytesToFile(testFilePath, 1); - - const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); - jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); - - jest.spyOn(fs, 'watch').mockImplementation( - () => - ({ - on: jest.fn((eventType, cb) => { - if (eventType === 'error') { - cb(); - } - }), - close: jest.fn(), - } as any) - ); - - await logRotator.start(); - - expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(false); - expect(logRotator.shouldUsePolling).toBe(true); - - await logRotator.stop(); - }); - - it('rotates log file service correctly fallback to usePolling true after defined timeout', async () => { - jest.useFakeTimers(); - writeBytesToFile(testFilePath, 1); - - const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); - jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); - jest.spyOn(fs, 'watch').mockImplementation( - () => - ({ - on: jest.fn((ev: string) => { - if (ev === 'error') { - jest.runTimersToTime(15000); - } - }), - close: jest.fn(), - } as any) - ); - - await logRotator.start(); - - expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(false); - expect(logRotator.shouldUsePolling).toBe(true); - - await logRotator.stop(); - jest.useRealTimers(); - }); -}); diff --git a/packages/kbn-legacy-logging/src/rotate/log_rotator.ts b/packages/kbn-legacy-logging/src/rotate/log_rotator.ts deleted file mode 100644 index 4b1e34839030f..0000000000000 --- a/packages/kbn-legacy-logging/src/rotate/log_rotator.ts +++ /dev/null @@ -1,352 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import * as chokidar from 'chokidar'; -import fs from 'fs'; -import { Server } from '@hapi/hapi'; -import { throttle } from 'lodash'; -import { tmpdir } from 'os'; -import { basename, dirname, join, sep } from 'path'; -import { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; -import { promisify } from 'util'; -import { LegacyLoggingConfig } from '../schema'; - -const mkdirAsync = promisify(fs.mkdir); -const readdirAsync = promisify(fs.readdir); -const renameAsync = promisify(fs.rename); -const statAsync = promisify(fs.stat); -const unlinkAsync = promisify(fs.unlink); -const writeFileAsync = promisify(fs.writeFile); - -export class LogRotator { - private readonly config: LegacyLoggingConfig; - private readonly log: Server['log']; - public logFilePath: string; - public everyBytes: number; - public keepFiles: number; - public running: boolean; - private logFileSize: number; - public isRotating: boolean; - public throttledRotate: () => void; - public stalker: chokidar.FSWatcher | null; - public usePolling: boolean; - public pollingInterval: number; - private stalkerUsePollingPolicyTestTimeout: NodeJS.Timeout | null; - public shouldUsePolling: boolean; - - constructor(config: LegacyLoggingConfig, server: Server) { - this.config = config; - this.log = server.log.bind(server); - this.logFilePath = config.dest; - this.everyBytes = config.rotate.everyBytes; - this.keepFiles = config.rotate.keepFiles; - this.running = false; - this.logFileSize = 0; - this.isRotating = false; - this.throttledRotate = throttle(async () => await this._rotate(), 5000); - this.stalker = null; - this.usePolling = config.rotate.usePolling; - this.pollingInterval = config.rotate.pollingInterval; - this.shouldUsePolling = false; - this.stalkerUsePollingPolicyTestTimeout = null; - } - - async start() { - if (this.running) { - return; - } - - this.running = true; - - // create exit listener for cleanup purposes - this._createExitListener(); - - // call rotate on startup - await this._callRotateOnStartup(); - - // init log file size monitor - await this._startLogFileSizeMonitor(); - } - - stop = () => { - if (!this.running) { - return; - } - - // cleanup exit listener - this._deleteExitListener(); - - // stop log file size monitor - this._stopLogFileSizeMonitor(); - - this.running = false; - }; - - async _shouldUsePolling() { - try { - // Setup a test file in order to try the fs env - // and understand if we need to usePolling or not - const tempFileDir = tmpdir(); - const tempFile = join(tempFileDir, 'kbn_log_rotation_use_polling_test_file.log'); - - await mkdirAsync(tempFileDir, { recursive: true }); - await writeFileAsync(tempFile, ''); - - // setup fs.watch for the temp test file - const testWatcher = fs.watch(tempFile, { persistent: false }); - - // await writeFileAsync(tempFile, 'test'); - - const usePollingTest$ = new Observable((observer) => { - // observable complete function - const completeFn = (completeStatus: boolean) => { - if (this.stalkerUsePollingPolicyTestTimeout) { - clearTimeout(this.stalkerUsePollingPolicyTestTimeout); - } - testWatcher.close(); - - observer.next(completeStatus); - observer.complete(); - }; - - // setup conditions that would fire the observable - this.stalkerUsePollingPolicyTestTimeout = setTimeout( - () => completeFn(true), - this.config.rotate.pollingPolicyTestTimeout || 15000 - ); - testWatcher.on('change', () => completeFn(false)); - testWatcher.on('error', () => completeFn(true)); - - // fire test watcher events - setTimeout(() => { - fs.writeFileSync(tempFile, 'test'); - }, 0); - }); - - // wait for the first observable result and consider it as the result - // for our use polling test - const usePollingTestResult = await usePollingTest$.pipe(first()).toPromise(); - - // delete the temp file used for the test - await unlinkAsync(tempFile); - - return usePollingTestResult; - } catch { - return true; - } - } - - async _startLogFileSizeMonitor() { - this.usePolling = this.config.rotate.usePolling; - this.shouldUsePolling = await this._shouldUsePolling(); - - if (this.usePolling && !this.shouldUsePolling) { - this.log( - ['warning', 'logging:rotate'], - 'Looks like your current environment support a faster algorithm than polling. You can try to disable `usePolling`' - ); - } - - if (!this.usePolling && this.shouldUsePolling) { - this.log( - ['error', 'logging:rotate'], - 'Looks like within your current environment you need to use polling in order to enable log rotator. Please enable `usePolling`' - ); - } - - this.stalker = chokidar.watch(this.logFilePath, { - ignoreInitial: true, - awaitWriteFinish: false, - useFsEvents: false, - usePolling: this.usePolling, - interval: this.pollingInterval, - binaryInterval: this.pollingInterval, - alwaysStat: true, - atomic: false, - }); - this.stalker.on('change', this._logFileSizeMonitorHandler); - } - - _logFileSizeMonitorHandler = async (filename: string, stats: fs.Stats) => { - if (!filename || !stats) { - return; - } - - this.logFileSize = stats.size || 0; - await this.throttledRotate(); - }; - - _stopLogFileSizeMonitor() { - if (!this.stalker) { - return; - } - - this.stalker.close(); - - if (this.stalkerUsePollingPolicyTestTimeout) { - clearTimeout(this.stalkerUsePollingPolicyTestTimeout); - } - } - - _createExitListener() { - process.on('exit', this.stop); - } - - _deleteExitListener() { - process.removeListener('exit', this.stop); - } - - async _getLogFileSizeAndCreateIfNeeded() { - try { - const logFileStats = await statAsync(this.logFilePath); - return logFileStats.size; - } catch { - // touch the file to make the watcher being able to register - // change events - await writeFileAsync(this.logFilePath, ''); - return 0; - } - } - - async _callRotateOnStartup() { - this.logFileSize = await this._getLogFileSizeAndCreateIfNeeded(); - await this._rotate(); - } - - _shouldRotate() { - // should rotate evaluation - // 1. should rotate if current log size exceeds - // the defined one on everyBytes - // 2. should not rotate if is already rotating or if any - // of the conditions on 1. do not apply - if (this.isRotating) { - return false; - } - - return this.logFileSize >= this.everyBytes; - } - - async _rotate() { - if (!this._shouldRotate()) { - return; - } - - await this._rotateNow(); - } - - async _rotateNow() { - // rotate process - // 1. get rotated files metadata (list of log rotated files present on the log folder, numerical sorted) - // 2. delete last file - // 3. rename all files to the correct index +1 - // 4. rename + compress current log into 1 - // 5. send SIGHUP to reload log config - - // rotate process is starting - this.isRotating = true; - - // get rotated files metadata - const foundRotatedFiles = await this._readRotatedFilesMetadata(); - - // delete number of rotated files exceeding the keepFiles limit setting - const rotatedFiles: string[] = await this._deleteFoundRotatedFilesAboveKeepFilesLimit( - foundRotatedFiles - ); - - // delete last file - await this._deleteLastRotatedFile(rotatedFiles); - - // rename all files to correct index + 1 - // and normalize numbering if by some reason - // (for example log file deletion) that numbering - // was interrupted - await this._renameRotatedFilesByOne(rotatedFiles); - - // rename current log into 0 - await this._rotateCurrentLogFile(); - - // send SIGHUP to reload log configuration - this._sendReloadLogConfigSignal(); - - // Reset log file size - this.logFileSize = 0; - - // rotate process is finished - this.isRotating = false; - } - - async _readRotatedFilesMetadata() { - const logFileBaseName = basename(this.logFilePath); - const logFilesFolder = dirname(this.logFilePath); - const foundLogFiles: string[] = await readdirAsync(logFilesFolder); - - return ( - foundLogFiles - .filter((file) => new RegExp(`${logFileBaseName}\\.\\d`).test(file)) - // we use .slice(-1) here in order to retrieve the last number match in the read filenames - .sort((a, b) => Number(a.match(/(\d+)/g)!.slice(-1)) - Number(b.match(/(\d+)/g)!.slice(-1))) - .map((filename) => `${logFilesFolder}${sep}${filename}`) - ); - } - - async _deleteFoundRotatedFilesAboveKeepFilesLimit(foundRotatedFiles: string[]) { - if (foundRotatedFiles.length <= this.keepFiles) { - return foundRotatedFiles; - } - - const finalRotatedFiles = foundRotatedFiles.slice(0, this.keepFiles); - const rotatedFilesToDelete = foundRotatedFiles.slice( - finalRotatedFiles.length, - foundRotatedFiles.length - ); - - await Promise.all( - rotatedFilesToDelete.map((rotatedFilePath: string) => unlinkAsync(rotatedFilePath)) - ); - - return finalRotatedFiles; - } - - async _deleteLastRotatedFile(rotatedFiles: string[]) { - if (rotatedFiles.length < this.keepFiles) { - return; - } - - const lastFilePath: string = rotatedFiles.pop() as string; - await unlinkAsync(lastFilePath); - } - - async _renameRotatedFilesByOne(rotatedFiles: string[]) { - const logFileBaseName = basename(this.logFilePath); - const logFilesFolder = dirname(this.logFilePath); - - for (let i = rotatedFiles.length - 1; i >= 0; i--) { - const oldFilePath = rotatedFiles[i]; - const newFilePath = `${logFilesFolder}${sep}${logFileBaseName}.${i + 1}`; - await renameAsync(oldFilePath, newFilePath); - } - } - - async _rotateCurrentLogFile() { - const newFilePath = `${this.logFilePath}.0`; - await renameAsync(this.logFilePath, newFilePath); - } - - _sendReloadLogConfigSignal() { - if (!process.env.isDevCliChild || !process.send) { - process.emit('SIGHUP', 'SIGHUP'); - return; - } - - // Send a special message to the cluster manager - // so it can forward it correctly - // It will only run when we are under cluster mode (not under a production environment) - process.send(['RELOAD_LOGGING_CONFIG_FROM_SERVER_WORKER']); - } -} diff --git a/packages/kbn-legacy-logging/src/schema.ts b/packages/kbn-legacy-logging/src/schema.ts deleted file mode 100644 index 0330708e746c0..0000000000000 --- a/packages/kbn-legacy-logging/src/schema.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { schema } from '@kbn/config-schema'; - -/** - * @deprecated - * - * Legacy logging has been deprecated and will be removed in 8.0. - * Set up logging from the platform logging instead - */ -export interface LegacyLoggingConfig { - silent: boolean; - quiet: boolean; - verbose: boolean; - events: Record; - dest: string; - filter: Record; - json: boolean; - timezone?: string; - rotate: { - enabled: boolean; - everyBytes: number; - keepFiles: number; - pollingInterval: number; - usePolling: boolean; - pollingPolicyTestTimeout?: number; - }; -} - -export const legacyLoggingConfigSchema = schema.object({ - silent: schema.boolean({ defaultValue: false }), - quiet: schema.conditional( - schema.siblingRef('silent'), - true, - schema.boolean({ - defaultValue: true, - validate: (quiet) => { - if (!quiet) { - return 'must be true when `silent` is true'; - } - }, - }), - schema.boolean({ defaultValue: false }) - ), - verbose: schema.conditional( - schema.siblingRef('quiet'), - true, - schema.boolean({ - defaultValue: false, - validate: (verbose) => { - if (verbose) { - return 'must be false when `quiet` is true'; - } - }, - }), - schema.boolean({ defaultValue: false }) - ), - events: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), - dest: schema.string({ defaultValue: 'stdout' }), - filter: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), - json: schema.conditional( - schema.siblingRef('dest'), - 'stdout', - schema.boolean({ - defaultValue: !process.stdout.isTTY, - }), - schema.boolean({ - defaultValue: true, - }) - ), - timezone: schema.maybe(schema.string()), - rotate: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - everyBytes: schema.number({ - min: 1048576, // > 1MB - max: 1073741825, // < 1GB - defaultValue: 10485760, // 10MB - }), - keepFiles: schema.number({ - min: 2, - max: 1024, - defaultValue: 7, - }), - pollingInterval: schema.number({ - min: 5000, - max: 3600000, - defaultValue: 10000, - }), - usePolling: schema.boolean({ defaultValue: false }), - }), -}); diff --git a/packages/kbn-legacy-logging/src/setup_logging.test.ts b/packages/kbn-legacy-logging/src/setup_logging.test.ts deleted file mode 100644 index 8e1d76477f64a..0000000000000 --- a/packages/kbn-legacy-logging/src/setup_logging.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { Server } from '@hapi/hapi'; -import { reconfigureLogging, setupLogging } from './setup_logging'; -import { LegacyLoggingConfig } from './schema'; - -describe('reconfigureLogging', () => { - test(`doesn't throw an error`, () => { - const server = new Server(); - const config: LegacyLoggingConfig = { - silent: false, - quiet: false, - verbose: true, - events: {}, - dest: '/tmp/foo', - filter: {}, - json: true, - rotate: { - enabled: false, - everyBytes: 0, - keepFiles: 0, - pollingInterval: 0, - usePolling: false, - }, - }; - setupLogging(server, config, 10); - reconfigureLogging(server, { ...config, dest: '/tmp/bar' }, 0); - }); -}); diff --git a/packages/kbn-legacy-logging/src/setup_logging.ts b/packages/kbn-legacy-logging/src/setup_logging.ts deleted file mode 100644 index a045469e81251..0000000000000 --- a/packages/kbn-legacy-logging/src/setup_logging.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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. - */ - -// @ts-expect-error missing typedef -import { plugin as good } from '@elastic/good'; -import { Server } from '@hapi/hapi'; -import { LegacyLoggingConfig } from './schema'; -import { getLoggingConfiguration } from './get_logging_config'; - -export async function setupLogging( - server: Server, - config: LegacyLoggingConfig, - opsInterval: number -) { - // NOTE: legacy logger creates a new stream for each new access - // In https://github.com/elastic/kibana/pull/55937 we reach the max listeners - // default limit of 10 for process.stdout which starts a long warning/error - // thrown every time we start the server. - // In order to keep using the legacy logger until we remove it I'm just adding - // a new hard limit here. - process.stdout.setMaxListeners(60); - - return await server.register({ - plugin: good, - options: getLoggingConfiguration(config, opsInterval), - }); -} - -export function reconfigureLogging( - server: Server, - config: LegacyLoggingConfig, - opsInterval: number -) { - const loggingOptions = getLoggingConfiguration(config, opsInterval); - (server.plugins as any).good.reconfigure(loggingOptions); -} diff --git a/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.test.ts b/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.test.ts deleted file mode 100644 index b662c88eba7b7..0000000000000 --- a/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { applyFiltersToKeys } from './apply_filters_to_keys'; - -describe('applyFiltersToKeys(obj, actionsByKey)', function () { - it('applies for each key+prop in actionsByKey', function () { - const data = applyFiltersToKeys( - { - a: { - b: { - c: 1, - }, - d: { - e: 'foobar', - }, - }, - req: { - headers: { - authorization: 'Basic dskd939k2i', - }, - }, - }, - { - b: 'remove', - e: 'censor', - authorization: '/([^\\s]+)$/', - } - ); - - expect(data).toEqual({ - a: { - d: { - e: 'XXXXXX', - }, - }, - req: { - headers: { - authorization: 'Basic XXXXXXXXXX', - }, - }, - }); - }); -}); diff --git a/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.ts b/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.ts deleted file mode 100644 index 578fa3a835129..0000000000000 --- a/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.ts +++ /dev/null @@ -1,50 +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. - */ - -function toPojo(obj: Record) { - return JSON.parse(JSON.stringify(obj)); -} - -function replacer(match: string, group: any[]) { - return new Array(group.length + 1).join('X'); -} - -function apply(obj: Record, key: string, action: string) { - for (const k in obj) { - if (obj.hasOwnProperty(k)) { - let val = obj[k]; - if (k === key) { - if (action === 'remove') { - delete obj[k]; - } else if (action === 'censor' && typeof val === 'object') { - delete obj[key]; - } else if (action === 'censor') { - obj[k] = ('' + val).replace(/./g, 'X'); - } else if (/\/.+\//.test(action)) { - const matches = action.match(/\/(.+)\//); - if (matches) { - const regex = new RegExp(matches[1]); - obj[k] = ('' + val).replace(regex, replacer); - } - } - } else if (typeof val === 'object') { - val = apply(val as Record, key, action); - } - } - } - return obj; -} - -export function applyFiltersToKeys( - obj: Record, - actionsByKey: Record -) { - return Object.keys(actionsByKey).reduce((output, key) => { - return apply(output, key, actionsByKey[key]); - }, toPojo(obj)); -} diff --git a/packages/kbn-legacy-logging/src/utils/get_payload_size.test.ts b/packages/kbn-legacy-logging/src/utils/get_payload_size.test.ts deleted file mode 100644 index 01d2cf29758db..0000000000000 --- a/packages/kbn-legacy-logging/src/utils/get_payload_size.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import mockFs from 'mock-fs'; -import { createReadStream } from 'fs'; -import { PassThrough } from 'stream'; -import { createGzip, createGunzip } from 'zlib'; - -import { getResponsePayloadBytes } from './get_payload_size'; - -describe('getPayloadSize', () => { - describe('handles Buffers', () => { - test('with ascii characters', () => { - const payload = 'heya'; - const result = getResponsePayloadBytes(Buffer.from(payload)); - expect(result).toBe(4); - }); - - test('with special characters', () => { - const payload = '¡hola!'; - const result = getResponsePayloadBytes(Buffer.from(payload)); - expect(result).toBe(7); - }); - }); - - describe('handles streams', () => { - afterEach(() => mockFs.restore()); - - test('ignores streams that are not fs or zlib streams', async () => { - const result = getResponsePayloadBytes(new PassThrough()); - expect(result).toBe(undefined); - }); - - describe('fs streams', () => { - test('with ascii characters', async () => { - mockFs({ 'test.txt': 'heya' }); - const readStream = createReadStream('test.txt'); - - let data = ''; - for await (const chunk of readStream) { - data += chunk; - } - - const result = getResponsePayloadBytes(readStream); - expect(result).toBe(Buffer.byteLength(data)); - }); - - test('with special characters', async () => { - mockFs({ 'test.txt': '¡hola!' }); - const readStream = createReadStream('test.txt'); - - let data = ''; - for await (const chunk of readStream) { - data += chunk; - } - - const result = getResponsePayloadBytes(readStream); - expect(result).toBe(Buffer.byteLength(data)); - }); - - describe('zlib streams', () => { - test('with ascii characters', async () => { - mockFs({ 'test.txt': 'heya' }); - const readStream = createReadStream('test.txt'); - const source = readStream.pipe(createGzip()).pipe(createGunzip()); - - let data = ''; - for await (const chunk of source) { - data += chunk; - } - - const result = getResponsePayloadBytes(source); - - expect(data).toBe('heya'); - expect(result).toBe(source.bytesWritten); - }); - - test('with special characters', async () => { - mockFs({ 'test.txt': '¡hola!' }); - const readStream = createReadStream('test.txt'); - const source = readStream.pipe(createGzip()).pipe(createGunzip()); - - let data = ''; - for await (const chunk of source) { - data += chunk; - } - - const result = getResponsePayloadBytes(source); - - expect(data).toBe('¡hola!'); - expect(result).toBe(source.bytesWritten); - }); - }); - }); - }); - - describe('handles plain responses', () => { - test('when source is text', () => { - const result = getResponsePayloadBytes('heya'); - expect(result).toBe(4); - }); - - test('when source contains special characters', () => { - const result = getResponsePayloadBytes('¡hola!'); - expect(result).toBe(7); - }); - - test('when source is object', () => { - const payload = { message: 'heya' }; - const result = getResponsePayloadBytes(payload); - expect(result).toBe(JSON.stringify(payload).length); - }); - - test('when source is array object', () => { - const payload = [{ message: 'hey' }, { message: 'ya' }]; - const result = getResponsePayloadBytes(payload); - expect(result).toBe(JSON.stringify(payload).length); - }); - - test('returns undefined when source is not plain object', () => { - class TestClass { - constructor() {} - } - const result = getResponsePayloadBytes(new TestClass()); - expect(result).toBe(undefined); - }); - }); - - describe('handles content-length header', () => { - test('always provides content-length header if available', () => { - const headers = { 'content-length': '123' }; - const result = getResponsePayloadBytes('heya', headers); - expect(result).toBe(123); - }); - - test('uses first value when hapi header is an array', () => { - const headers = { 'content-length': ['123', '456'] }; - const result = getResponsePayloadBytes(null, headers); - expect(result).toBe(123); - }); - - test('returns undefined if length is NaN', () => { - const headers = { 'content-length': 'oops' }; - const result = getResponsePayloadBytes(null, headers); - expect(result).toBeUndefined(); - }); - }); - - test('defaults to undefined', () => { - const result = getResponsePayloadBytes(null); - expect(result).toBeUndefined(); - }); -}); diff --git a/packages/kbn-legacy-logging/src/utils/get_payload_size.ts b/packages/kbn-legacy-logging/src/utils/get_payload_size.ts deleted file mode 100644 index acc517c74c2d4..0000000000000 --- a/packages/kbn-legacy-logging/src/utils/get_payload_size.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { isPlainObject } from 'lodash'; -import { ReadStream } from 'fs'; -import { Zlib } from 'zlib'; -import type { ResponseObject } from '@hapi/hapi'; - -const isBuffer = (obj: unknown): obj is Buffer => Buffer.isBuffer(obj); -const isFsReadStream = (obj: unknown): obj is ReadStream => - typeof obj === 'object' && obj !== null && 'bytesRead' in obj && obj instanceof ReadStream; -const isZlibStream = (obj: unknown): obj is Zlib => { - return typeof obj === 'object' && obj !== null && 'bytesWritten' in obj; -}; -const isString = (obj: unknown): obj is string => typeof obj === 'string'; - -/** - * Attempts to determine the size (in bytes) of a hapi/good - * responsePayload based on the payload type. Falls back to - * `undefined` if the size cannot be determined. - * - * This is similar to the implementation in `core/server/http/logging`, - * however it uses more duck typing as we do not have access to the - * entire hapi request object like we do in the HttpServer. - * - * @param headers responseHeaders from hapi/good event - * @param payload responsePayload from hapi/good event - * - * @internal - */ -export function getResponsePayloadBytes( - payload: ResponseObject['source'], - headers: Record = {} -): number | undefined { - const contentLength = headers['content-length']; - if (contentLength) { - const val = parseInt( - // hapi response headers can be `string | string[]`, so we need to handle both cases - Array.isArray(contentLength) ? String(contentLength) : contentLength, - 10 - ); - return !isNaN(val) ? val : undefined; - } - - if (isBuffer(payload)) { - return payload.byteLength; - } - - if (isFsReadStream(payload)) { - return payload.bytesRead; - } - - if (isZlibStream(payload)) { - return payload.bytesWritten; - } - - if (isString(payload)) { - return Buffer.byteLength(payload); - } - - if (isPlainObject(payload) || Array.isArray(payload)) { - return Buffer.byteLength(JSON.stringify(payload)); - } - - return undefined; -} diff --git a/packages/kbn-legacy-logging/tsconfig.json b/packages/kbn-legacy-logging/tsconfig.json deleted file mode 100644 index 55047dbcadc91..0000000000000 --- a/packages/kbn-legacy-logging/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.bazel.json", - "compilerOptions": { - "declaration": true, - "declarationMap": true, - "emitDeclarationOnly": true, - "outDir": "target_types", - "rootDir": "src", - "sourceMap": true, - "sourceRoot": "../../../../packages/kbn-legacy-logging/src", - "stripInternal": false, - "types": ["jest", "node"] - }, - "include": ["src/**/*"] -} diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index c96a1eb28cfce..cab1f6d916f02 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -9014,6 +9014,7 @@ class CiStatsReporter { const upstreamBranch = (_options$upstreamBran = options.upstreamBranch) !== null && _options$upstreamBran !== void 0 ? _options$upstreamBran : this.getUpstreamBranch(); const kibanaUuid = options.kibanaUuid === undefined ? this.getKibanaUuid() : options.kibanaUuid; let email; + let branch; try { const { @@ -9024,16 +9025,32 @@ class CiStatsReporter { this.log.debug(e.message); } + try { + const { + stdout + } = await (0, _execa.default)('git', ['branch', '--show-current']); + branch = stdout; + } catch (e) { + this.log.debug(e.message); + } + + const memUsage = process.memoryUsage(); const isElasticCommitter = email && email.endsWith('@elastic.co') ? true : false; const defaultMetadata = { + kibanaUuid, + isElasticCommitter, committerHash: email ? _crypto.default.createHash('sha256').update(email).digest('hex').substring(0, 20) : undefined, + email: isElasticCommitter ? email : undefined, + branch: isElasticCommitter ? branch : undefined, cpuCount: (_Os$cpus = _os.default.cpus()) === null || _Os$cpus === void 0 ? void 0 : _Os$cpus.length, cpuModel: (_Os$cpus$ = _os.default.cpus()[0]) === null || _Os$cpus$ === void 0 ? void 0 : _Os$cpus$.model, cpuSpeed: (_Os$cpus$2 = _os.default.cpus()[0]) === null || _Os$cpus$2 === void 0 ? void 0 : _Os$cpus$2.speed, - email: isElasticCommitter ? email : undefined, freeMem: _os.default.freemem(), - isElasticCommitter, - kibanaUuid, + memoryUsageRss: memUsage.rss, + memoryUsageHeapTotal: memUsage.heapTotal, + memoryUsageHeapUsed: memUsage.heapUsed, + memoryUsageExternal: memUsage.external, + memoryUsageArrayBuffers: memUsage.arrayBuffers, nestedTiming: process.env.CI_STATS_NESTED_TIMING ? true : false, osArch: _os.default.arch(), osPlatform: _os.default.platform(), diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index 86a036bbb9fe2..6ac897bbafb08 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -52,6 +52,7 @@ const ALERT_RULE_LICENSE = `${ALERT_RULE_NAMESPACE}.license` as const; const ALERT_RULE_CATEGORY = `${ALERT_RULE_NAMESPACE}.category` as const; const ALERT_RULE_NAME = `${ALERT_RULE_NAMESPACE}.name` as const; const ALERT_RULE_NOTE = `${ALERT_RULE_NAMESPACE}.note` as const; +const ALERT_RULE_PARAMS = `${ALERT_RULE_NAMESPACE}.params` as const; const ALERT_RULE_REFERENCES = `${ALERT_RULE_NAMESPACE}.references` as const; const ALERT_RULE_RISK_SCORE = `${ALERT_RULE_NAMESPACE}.risk_score` as const; const ALERT_RULE_RISK_SCORE_MAPPING = `${ALERT_RULE_NAMESPACE}.risk_score_mapping` as const; @@ -109,6 +110,7 @@ const fields = { ALERT_RULE_LICENSE, ALERT_RULE_NAME, ALERT_RULE_NOTE, + ALERT_RULE_PARAMS, ALERT_RULE_REFERENCES, ALERT_RULE_RISK_SCORE, ALERT_RULE_RISK_SCORE_MAPPING, @@ -164,6 +166,7 @@ export { ALERT_RULE_LICENSE, ALERT_RULE_NAME, ALERT_RULE_NOTE, + ALERT_RULE_PARAMS, ALERT_RULE_REFERENCES, ALERT_RULE_RISK_SCORE, ALERT_RULE_RISK_SCORE_MAPPING, diff --git a/packages/kbn-test/src/functional_test_runner/cli.ts b/packages/kbn-test/src/functional_test_runner/cli.ts index ccd578aa038f8..3ad365a028b65 100644 --- a/packages/kbn-test/src/functional_test_runner/cli.ts +++ b/packages/kbn-test/src/functional_test_runner/cli.ts @@ -9,7 +9,7 @@ import { resolve } from 'path'; import { inspect } from 'util'; -import { run, createFlagError, Flags } from '@kbn/dev-utils'; +import { run, createFlagError, Flags, ToolingLog, getTimeReporter } from '@kbn/dev-utils'; import exitHook from 'exit-hook'; import { FunctionalTestRunner } from './functional_test_runner'; @@ -27,6 +27,12 @@ const parseInstallDir = (flags: Flags) => { }; export function runFtrCli() { + const runStartTime = Date.now(); + const toolingLog = new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }); + const reportTime = getTimeReporter(toolingLog, 'scripts/functional_test_runner'); run( async ({ flags, log }) => { const functionalTestRunner = new FunctionalTestRunner( @@ -68,9 +74,19 @@ export function runFtrCli() { teardownRun = true; if (err) { + await reportTime(runStartTime, 'total', { + success: false, + err: err.message, + ...flags, + }); log.indent(-log.indent()); log.error(err); process.exitCode = 1; + } else { + await reportTime(runStartTime, 'total', { + success: true, + ...flags, + }); } try { diff --git a/packages/kbn-test/src/functional_tests/cli/start_servers/cli.js b/packages/kbn-test/src/functional_tests/cli/start_servers/cli.js index 824cf3e6ceec1..df7f8750b2ae3 100644 --- a/packages/kbn-test/src/functional_tests/cli/start_servers/cli.js +++ b/packages/kbn-test/src/functional_tests/cli/start_servers/cli.js @@ -18,6 +18,8 @@ import { processOptions, displayHelp } from './args'; export async function startServersCli(defaultConfigPath) { await runCli(displayHelp, async (userOptions) => { const options = processOptions(userOptions, defaultConfigPath); - await startServers(options); + await startServers({ + ...options, + }); }); } diff --git a/packages/kbn-test/src/functional_tests/tasks.ts b/packages/kbn-test/src/functional_tests/tasks.ts index d45f8656ed728..3bc697c143f40 100644 --- a/packages/kbn-test/src/functional_tests/tasks.ts +++ b/packages/kbn-test/src/functional_tests/tasks.ts @@ -9,7 +9,7 @@ import { relative } from 'path'; import * as Rx from 'rxjs'; import { startWith, switchMap, take } from 'rxjs/operators'; -import { withProcRunner, ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; +import { withProcRunner, ToolingLog, REPO_ROOT, getTimeReporter } from '@kbn/dev-utils'; import dedent from 'dedent'; import { @@ -147,7 +147,14 @@ interface StartServerOptions { useDefaultConfig?: boolean; } -export async function startServers(options: StartServerOptions) { +export async function startServers({ ...options }: StartServerOptions) { + const runStartTime = Date.now(); + const toolingLog = new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }); + const reportTime = getTimeReporter(toolingLog, 'scripts/functional_tests_server'); + const log = options.createLogger(); const opts = { ...options, @@ -170,6 +177,11 @@ export async function startServers(options: StartServerOptions) { }, }); + reportTime(runStartTime, 'ready', { + success: true, + ...options, + }); + // wait for 5 seconds of silence before logging the // success message so that it doesn't get buried await silence(log, 5000); diff --git a/packages/kbn-test/src/jest/run.ts b/packages/kbn-test/src/jest/run.ts index 441104befde91..07610a3eb84c6 100644 --- a/packages/kbn-test/src/jest/run.ts +++ b/packages/kbn-test/src/jest/run.ts @@ -21,7 +21,8 @@ import { resolve, relative, sep as osSep } from 'path'; import { existsSync } from 'fs'; import { run } from 'jest'; import { buildArgv } from 'jest-cli/build/cli'; -import { ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog, getTimeReporter } from '@kbn/dev-utils'; +import { map } from 'lodash'; // yarn test:jest src/core/server/saved_objects // yarn test:jest src/core/public/core_system.test.ts @@ -35,9 +36,14 @@ export function runJest(configName = 'jest.config.js') { writeTo: process.stdout, }); + const runStartTime = Date.now(); + const reportTime = getTimeReporter(log, 'scripts/jest'); + let cwd: string; + let testFiles: string[]; + if (!argv.config) { - const cwd = process.env.INIT_CWD || process.cwd(); - const testFiles = argv._.splice(2).map((p) => resolve(cwd, p)); + cwd = process.env.INIT_CWD || process.cwd(); + testFiles = argv._.splice(2).map((p) => resolve(cwd, p)); const commonTestFiles = commonBasePath(testFiles); const testFilesProvided = testFiles.length > 0; @@ -73,7 +79,14 @@ export function runJest(configName = 'jest.config.js') { process.env.NODE_ENV = 'test'; } - run(); + run().then(() => { + // Success means that tests finished, doesn't mean they passed. + reportTime(runStartTime, 'total', { + success: true, + isXpack: cwd.includes('x-pack'), + testFiles: map(testFiles, (testFile) => relative(cwd, testFile)), + }); + }); } /** diff --git a/packages/kbn-typed-react-router-config/src/create_router.ts b/packages/kbn-typed-react-router-config/src/create_router.ts index 13f09e7546de5..77c2bba14e85a 100644 --- a/packages/kbn-typed-react-router-config/src/create_router.ts +++ b/packages/kbn-typed-react-router-config/src/create_router.ts @@ -20,7 +20,7 @@ import type { deepExactRt as deepExactRtTyped, mergeRt as mergeRtTyped } from '@ import { deepExactRt as deepExactRtNonTyped } from '@kbn/io-ts-utils/target_node/deep_exact_rt'; // @ts-expect-error import { mergeRt as mergeRtNonTyped } from '@kbn/io-ts-utils/target_node/merge_rt'; -import { Route, Router } from './types'; +import { FlattenRoutesOf, Route, Router } from './types'; const deepExactRt: typeof deepExactRtTyped = deepExactRtNonTyped; const mergeRt: typeof mergeRtTyped = mergeRtNonTyped; @@ -51,6 +51,20 @@ export function createRouter(routes: TRoutes): Router { + return routesByReactRouterConfig.get(match.route)!; + }); + + return matchedRoutes; + } + const matchRoutes = (...args: any[]) => { let optional: boolean = false; @@ -142,15 +156,7 @@ export function createRouter(routes: TRoutes): Router { - return routesByReactRouterConfig.get(match.route)!; - }); + const matchedRoutes = getRoutesToMatch(path); const validationType = mergeRt( ...(compact( @@ -200,5 +206,8 @@ export function createRouter(routes: TRoutes): Router { return reactRouterConfigsByRoute.get(route)!.path as string; }, + getRoutesToMatch: (path: string) => { + return getRoutesToMatch(path) as unknown as FlattenRoutesOf; + }, }; } diff --git a/packages/kbn-typed-react-router-config/src/outlet.tsx b/packages/kbn-typed-react-router-config/src/outlet.tsx index 696085489abee..9af7b8bdd6422 100644 --- a/packages/kbn-typed-react-router-config/src/outlet.tsx +++ b/packages/kbn-typed-react-router-config/src/outlet.tsx @@ -5,9 +5,24 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useCurrentRoute } from './use_current_route'; +import React, { createContext, useContext } from 'react'; + +const OutletContext = createContext<{ element?: React.ReactElement } | undefined>(undefined); + +export function OutletContextProvider({ + element, + children, +}: { + element: React.ReactElement; + children: React.ReactNode; +}) { + return {children}; +} export function Outlet() { - const { element } = useCurrentRoute(); - return element; + const outletContext = useContext(OutletContext); + if (!outletContext) { + throw new Error('Outlet context not available'); + } + return outletContext.element || null; } diff --git a/packages/kbn-typed-react-router-config/src/router_provider.tsx b/packages/kbn-typed-react-router-config/src/router_provider.tsx index d2512ba8fe426..657df9e9fc592 100644 --- a/packages/kbn-typed-react-router-config/src/router_provider.tsx +++ b/packages/kbn-typed-react-router-config/src/router_provider.tsx @@ -18,7 +18,7 @@ export function RouterProvider({ }: { router: Router; history: History; - children: React.ReactElement; + children: React.ReactNode; }) { return ( diff --git a/packages/kbn-typed-react-router-config/src/types/index.ts b/packages/kbn-typed-react-router-config/src/types/index.ts index 9c19c8dca323b..c1ae5afd816ee 100644 --- a/packages/kbn-typed-react-router-config/src/types/index.ts +++ b/packages/kbn-typed-react-router-config/src/types/index.ts @@ -147,6 +147,7 @@ interface PlainRoute { children?: PlainRoute[]; params?: t.Type; defaults?: Record>; + pre?: ReactElement; } interface ReadonlyPlainRoute { @@ -155,6 +156,7 @@ interface ReadonlyPlainRoute { readonly children?: readonly ReadonlyPlainRoute[]; readonly params?: t.Type; readonly defaults?: Record>; + pre?: ReactElement; } export type Route = PlainRoute | ReadonlyPlainRoute; @@ -209,6 +211,10 @@ export type TypeAsArgs = keyof TObject extends never ? [TObject] | [] : [TObject]; +export type FlattenRoutesOf = Array< + Omit>, 'parents'> +>; + export interface Router { matchRoutes>( path: TPath, @@ -245,6 +251,7 @@ export interface Router { ...args: TypeAsArgs> ): string; getRoutePath(route: Route): string; + getRoutesToMatch(path: string): FlattenRoutesOf; } type AppendPath< @@ -256,23 +263,21 @@ type MaybeUnion, U extends Record> = [key in keyof U]: key extends keyof T ? T[key] | U[key] : U[key]; }; -type MapRoute = TRoute extends Route - ? MaybeUnion< - { - [key in TRoute['path']]: TRoute & { parents: TParents }; - }, - TRoute extends { children: Route[] } - ? MaybeUnion< - MapRoutes, - { - [key in AppendPath]: ValuesType< - MapRoutes - >; - } - > - : {} - > - : {}; +type MapRoute = MaybeUnion< + { + [key in TRoute['path']]: TRoute & { parents: TParents }; + }, + TRoute extends { children: Route[] } + ? MaybeUnion< + MapRoutes, + { + [key in AppendPath]: ValuesType< + MapRoutes + >; + } + > + : {} +>; type MapRoutes = TRoutes extends [Route] ? MapRoute diff --git a/packages/kbn-typed-react-router-config/src/use_current_route.tsx b/packages/kbn-typed-react-router-config/src/use_current_route.tsx index 9227b119107b3..a36e6f4ec9c8e 100644 --- a/packages/kbn-typed-react-router-config/src/use_current_route.tsx +++ b/packages/kbn-typed-react-router-config/src/use_current_route.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import React, { createContext, useContext } from 'react'; +import { OutletContextProvider } from './outlet'; import { RouteMatch } from './types'; const CurrentRouteContext = createContext< @@ -23,7 +24,7 @@ export const CurrentRouteContextProvider = ({ }) => { return ( - {children} + {children} ); }; diff --git a/packages/kbn-typed-react-router-config/src/use_match_routes.ts b/packages/kbn-typed-react-router-config/src/use_match_routes.ts index b818ff06e9ae6..12c5af1f4412d 100644 --- a/packages/kbn-typed-react-router-config/src/use_match_routes.ts +++ b/packages/kbn-typed-react-router-config/src/use_match_routes.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { RouteMatch } from './types'; import { useRouter } from './use_router'; @@ -14,7 +14,11 @@ export function useMatchRoutes(path?: string): RouteMatch[] { const router = useRouter(); const location = useLocation(); - return typeof path === 'undefined' - ? router.matchRoutes(location) - : router.matchRoutes(path as never, location); + const routeMatches = useMemo(() => { + return typeof path === 'undefined' + ? router.matchRoutes(location) + : router.matchRoutes(path as never, location); + }, [path, router, location]); + + return routeMatches; } diff --git a/packages/kbn-typed-react-router-config/src/use_router.tsx b/packages/kbn-typed-react-router-config/src/use_router.tsx index b54530ed0fbdb..c78e85650f26d 100644 --- a/packages/kbn-typed-react-router-config/src/use_router.tsx +++ b/packages/kbn-typed-react-router-config/src/use_router.tsx @@ -16,7 +16,7 @@ export const RouterContextProvider = ({ children, }: { router: Router; - children: React.ReactElement; + children: React.ReactNode; }) => {children}; export function useRouter(): Router { diff --git a/packages/kbn-utils/src/path/index.ts b/packages/kbn-utils/src/path/index.ts index 9ee699c22c30c..15d6a3eddf01e 100644 --- a/packages/kbn-utils/src/path/index.ts +++ b/packages/kbn-utils/src/path/index.ts @@ -15,14 +15,12 @@ const isString = (v: any): v is string => typeof v === 'string'; const CONFIG_PATHS = [ process.env.KBN_PATH_CONF && join(process.env.KBN_PATH_CONF, 'kibana.yml'), - process.env.KIBANA_PATH_CONF && join(process.env.KIBANA_PATH_CONF, 'kibana.yml'), // deprecated join(REPO_ROOT, 'config/kibana.yml'), '/etc/kibana/kibana.yml', ].filter(isString); const CONFIG_DIRECTORIES = [ process.env.KBN_PATH_CONF, - process.env.KIBANA_PATH_CONF, // deprecated join(REPO_ROOT, 'config'), '/etc/kibana', ].filter(isString); diff --git a/src/cli/serve/integration_tests/__fixtures__/invalid_config.yml b/src/cli/serve/integration_tests/__fixtures__/invalid_config.yml index df9ea641cd3fe..d8e59ced89c80 100644 --- a/src/cli/serve/integration_tests/__fixtures__/invalid_config.yml +++ b/src/cli/serve/integration_tests/__fixtures__/invalid_config.yml @@ -1,3 +1,13 @@ +logging: + root: + level: fatal + appenders: [console-json] + appenders: + console-json: + type: console + layout: + type: json + unknown: key: 1 diff --git a/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana.test.yml b/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana.test.yml deleted file mode 100644 index 1761a7984e0e7..0000000000000 --- a/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana.test.yml +++ /dev/null @@ -1,13 +0,0 @@ -server: - autoListen: false - port: 8274 -logging: - json: true -optimize: - enabled: false -plugins: - initialize: false -migrations: - skip: true -elasticsearch: - skipStartupConnectionCheck: true diff --git a/src/cli/serve/integration_tests/invalid_config.test.ts b/src/cli/serve/integration_tests/invalid_config.test.ts index 724998699da85..2de902582a548 100644 --- a/src/cli/serve/integration_tests/invalid_config.test.ts +++ b/src/cli/serve/integration_tests/invalid_config.test.ts @@ -14,14 +14,15 @@ const INVALID_CONFIG_PATH = require.resolve('./__fixtures__/invalid_config.yml') interface LogEntry { message: string; - tags?: string[]; - type: string; + log: { + level: string; + }; } -describe('cli invalid config support', function () { +describe('cli invalid config support', () => { it( - 'exits with statusCode 64 and logs a single line when config is invalid', - function () { + 'exits with statusCode 64 and logs an error when config is invalid', + () => { // Unused keys only throw once LegacyService starts, so disable migrations so that Core // will finish the start lifecycle without a running Elasticsearch instance. const { error, status, stdout, stderr } = spawnSync( @@ -31,41 +32,27 @@ describe('cli invalid config support', function () { cwd: REPO_ROOT, } ); + expect(error).toBe(undefined); - let fatalLogLine; + let fatalLogEntries; try { - [fatalLogLine] = stdout + fatalLogEntries = stdout .toString('utf8') .split('\n') .filter(Boolean) .map((line) => JSON.parse(line) as LogEntry) - .filter((line) => line.tags?.includes('fatal')) - .map((obj) => ({ - ...obj, - pid: '## PID ##', - '@timestamp': '## @timestamp ##', - error: '## Error with stack trace ##', - })); + .filter((line) => line.log.level === 'FATAL'); } catch (e) { throw new Error( `error parsing log output:\n\n${e.stack}\n\nstdout: \n${stdout}\n\nstderr:\n${stderr}` ); } - expect(error).toBe(undefined); - - if (!fatalLogLine) { - throw new Error( - `cli did not log the expected fatal error message:\n\nstdout: \n${stdout}\n\nstderr:\n${stderr}` - ); - } - - expect(fatalLogLine.message).toContain( - 'Error: Unknown configuration key(s): "unknown.key", "other.unknown.key", "other.third", "some.flat.key", ' + + expect(fatalLogEntries).toHaveLength(1); + expect(fatalLogEntries[0].message).toContain( + 'Unknown configuration key(s): "unknown.key", "other.unknown.key", "other.third", "some.flat.key", ' + '"some.array". Check for spelling errors and ensure that expected plugins are installed.' ); - expect(fatalLogLine.tags).toEqual(['fatal', 'root']); - expect(fatalLogLine.type).toEqual('log'); expect(status).toBe(64); }, diff --git a/src/cli/serve/integration_tests/reload_logging_config.test.ts b/src/cli/serve/integration_tests/reload_logging_config.test.ts index 80ce52661565c..4cee7dfae4126 100644 --- a/src/cli/serve/integration_tests/reload_logging_config.test.ts +++ b/src/cli/serve/integration_tests/reload_logging_config.test.ts @@ -17,7 +17,6 @@ import { map, filter, take } from 'rxjs/operators'; import { safeDump } from 'js-yaml'; import { getConfigFromFiles } from '@kbn/config'; -const legacyConfig = follow('__fixtures__/reload_logging_config/kibana.test.yml'); const configFileLogConsole = follow( '__fixtures__/reload_logging_config/kibana_log_console.test.yml' ); @@ -96,81 +95,6 @@ describe.skip('Server logging configuration', function () { return; } - describe('legacy logging', () => { - it( - 'should be reloadable via SIGHUP process signaling', - async function () { - const configFilePath = Path.resolve(tempDir, 'kibana.yml'); - Fs.copyFileSync(legacyConfig, configFilePath); - - child = Child.spawn(process.execPath, [ - kibanaPath, - '--oss', - '--config', - configFilePath, - '--verbose', - ]); - - // TypeScript note: As long as the child stdio[1] is 'pipe', then stdout will not be null - const message$ = Rx.fromEvent(child.stdout!, 'data').pipe( - map((messages) => String(messages).split('\n').filter(Boolean)) - ); - - await message$ - .pipe( - // We know the sighup handler will be registered before this message logged - filter((messages: string[]) => messages.some((m) => m.includes('setting up root'))), - take(1) - ) - .toPromise(); - - const lastMessage = await message$.pipe(take(1)).toPromise(); - expect(containsJsonOnly(lastMessage)).toBe(true); - - createConfigManager(configFilePath).modify((oldConfig) => { - oldConfig.logging.json = false; - return oldConfig; - }); - - child.kill('SIGHUP'); - - await message$ - .pipe( - filter((messages) => !containsJsonOnly(messages)), - take(1) - ) - .toPromise(); - }, - minute - ); - - it( - 'should recreate file handle on SIGHUP', - async function () { - const logPath = Path.resolve(tempDir, 'kibana.log'); - const logPathArchived = Path.resolve(tempDir, 'kibana_archive.log'); - - child = Child.spawn(process.execPath, [ - kibanaPath, - '--oss', - '--config', - legacyConfig, - '--logging.dest', - logPath, - '--verbose', - ]); - - await watchFileUntil(logPath, /setting up root/, 30 * second); - // once the server is running, archive the log file and issue SIGHUP - Fs.renameSync(logPath, logPathArchived); - child.kill('SIGHUP'); - - await watchFileUntil(logPath, /Reloaded logging configuration due to SIGHUP/, 30 * second); - }, - minute - ); - }); - describe('platform logging', () => { it( 'should be reloadable via SIGHUP process signaling', diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 705acfe4fdf54..8b346d38cfea8 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -124,17 +124,12 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { if (opts.elasticsearch) set('elasticsearch.hosts', opts.elasticsearch.split(',')); if (opts.port) set('server.port', opts.port); if (opts.host) set('server.host', opts.host); + if (opts.silent) { - set('logging.silent', true); set('logging.root.level', 'off'); } if (opts.verbose) { - if (has('logging.root.appenders')) { - set('logging.root.level', 'all'); - } else { - // Only set logging.verbose to true for legacy logging when KP logging isn't configured. - set('logging.verbose', true); - } + set('logging.root.level', 'all'); } set('plugins.paths', _.compact([].concat(get('plugins.paths'), opts.pluginPath))); @@ -159,9 +154,8 @@ export default function (program) { [getConfigPath()] ) .option('-p, --port ', 'The port to bind to', parseInt) - .option('-q, --quiet', 'Deprecated, set logging level in your configuration') - .option('-Q, --silent', 'Prevent all logging') - .option('--verbose', 'Turns on verbose logging') + .option('-Q, --silent', 'Set the root logger level to off') + .option('--verbose', 'Set the root logger level to all') .option('-H, --host ', 'The host to bind to') .option( '-l, --log-file ', @@ -217,8 +211,6 @@ export default function (program) { const cliArgs = { dev: !!opts.dev, envName: unknownOptions.env ? unknownOptions.env.name : undefined, - // no longer supported - quiet: !!opts.quiet, silent: !!opts.silent, verbose: !!opts.verbose, watch: !!opts.watch, diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 0fe1347d299f9..624d6d10992cd 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -470,6 +470,19 @@ export class DocLinksService { ecs: { guide: `${ELASTIC_WEBSITE_URL}guide/en/ecs/current/index.html`, }, + clients: { + /** Changes to these URLs must also be synched in src/plugins/custom_integrations/server/language_clients/index.ts */ + guide: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/index.html`, + goOverview: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/go-api/${DOC_LINK_VERSION}/overview.html`, + javaIndex: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/java-api-client/${DOC_LINK_VERSION}/index.html`, + jsIntro: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/javascript-api/${DOC_LINK_VERSION}/introduction.html`, + netGuide: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/net-api/${DOC_LINK_VERSION}/index.html`, + perlGuide: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/perl-api/${DOC_LINK_VERSION}/index.html`, + phpGuide: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/php-api/${DOC_LINK_VERSION}/index.html`, + pythonGuide: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/net-api/${DOC_LINK_VERSION}/index.html`, + rubyOverview: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/ruby-api/${DOC_LINK_VERSION}/ruby_client.html`, + rustGuide: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/client/rust-api/${DOC_LINK_VERSION}/index.html`, + }, }, }); } @@ -706,5 +719,17 @@ export interface DocLinksStart { readonly ecs: { readonly guide: string; }; + readonly clients: { + readonly guide: string; + readonly goOverview: string; + readonly javaIndex: string; + readonly jsIntro: string; + readonly netGuide: string; + readonly perlGuide: string; + readonly phpGuide: string; + readonly pythonGuide: string; + readonly rubyOverview: string; + readonly rustGuide: string; + }; }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index eace9c4011942..7871558574074 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -459,9 +459,13 @@ export const DEFAULT_APP_CATEGORIES: Record; // @public export interface DeprecationsServiceStart { + // Warning: (ae-incompatible-release-tags) The symbol "getAllDeprecations" is marked as @public, but its signature references "DomainDeprecationDetails" which is marked as @internal getAllDeprecations: () => Promise; + // Warning: (ae-incompatible-release-tags) The symbol "getDeprecations" is marked as @public, but its signature references "DomainDeprecationDetails" which is marked as @internal getDeprecations: (domainId: string) => Promise; + // Warning: (ae-incompatible-release-tags) The symbol "isDeprecationResolvable" is marked as @public, but its signature references "DomainDeprecationDetails" which is marked as @internal isDeprecationResolvable: (details: DomainDeprecationDetails) => boolean; + // Warning: (ae-incompatible-release-tags) The symbol "resolveDeprecation" is marked as @public, but its signature references "DomainDeprecationDetails" which is marked as @internal resolveDeprecation: (details: DomainDeprecationDetails) => Promise; } @@ -699,13 +703,24 @@ export interface DocLinksStart { readonly ecs: { readonly guide: string; }; + readonly clients: { + readonly guide: string; + readonly goOverview: string; + readonly javaIndex: string; + readonly jsIntro: string; + readonly netGuide: string; + readonly perlGuide: string; + readonly phpGuide: string; + readonly pythonGuide: string; + readonly rubyOverview: string; + readonly rustGuide: string; + }; }; } // Warning: (ae-forgotten-export) The symbol "DeprecationsDetails" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "DomainDeprecationDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @internal (undocumented) export interface DomainDeprecationDetails extends DeprecationsDetails { // (undocumented) domainId: string; diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index d3a4d7f997062..95e23561a9378 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -8,6 +8,7 @@ import { getDeprecationsForGlobalSettings } from '../test_utils'; import { coreDeprecationProvider } from './core_deprecations'; + const initialEnv = { ...process.env }; const applyCoreDeprecations = (settings?: Record) => @@ -18,38 +19,6 @@ describe('core deprecations', () => { process.env = { ...initialEnv }; }); - describe('kibanaPathConf', () => { - it('logs a warning if KIBANA_PATH_CONF environ variable is set', () => { - process.env.KIBANA_PATH_CONF = 'somepath'; - const { messages } = applyCoreDeprecations(); - expect(messages).toMatchInlineSnapshot(` - Array [ - "Environment variable \\"KIBANA_PATH_CONF\\" is deprecated. It has been replaced with \\"KBN_PATH_CONF\\" pointing to a config folder", - ] - `); - }); - - it('does not log a warning if KIBANA_PATH_CONF environ variable is unset', () => { - delete process.env.KIBANA_PATH_CONF; - const { messages } = applyCoreDeprecations(); - expect(messages).toHaveLength(0); - }); - }); - - describe('xsrfDeprecation', () => { - it('logs a warning if server.xsrf.whitelist is set', () => { - const { migrated, messages } = applyCoreDeprecations({ - server: { xsrf: { whitelist: ['/path'] } }, - }); - expect(migrated.server.xsrf.allowlist).toEqual(['/path']); - expect(messages).toMatchInlineSnapshot(` - Array [ - "Setting \\"server.xsrf.whitelist\\" has been replaced by \\"server.xsrf.allowlist\\"", - ] - `); - }); - }); - describe('server.cors', () => { it('renames server.cors to server.cors.enabled', () => { const { migrated } = applyCoreDeprecations({ @@ -57,8 +26,9 @@ describe('core deprecations', () => { }); expect(migrated.server.cors).toEqual({ enabled: true }); }); + it('logs a warning message about server.cors renaming', () => { - const { messages } = applyCoreDeprecations({ + const { messages, levels } = applyCoreDeprecations({ server: { cors: true }, }); expect(messages).toMatchInlineSnapshot(` @@ -66,7 +36,13 @@ describe('core deprecations', () => { "\\"server.cors\\" is deprecated and has been replaced by \\"server.cors.enabled\\"", ] `); + expect(levels).toMatchInlineSnapshot(` + Array [ + "warning", + ] + `); }); + it('does not log deprecation message when server.cors.enabled set', () => { const { migrated, messages } = applyCoreDeprecations({ server: { cors: { enabled: true } }, @@ -203,230 +179,4 @@ describe('core deprecations', () => { ).toEqual([`worker-src blob:`]); }); }); - - describe('logging.events.ops', () => { - it('warns when ops events are used', () => { - const { messages } = applyCoreDeprecations({ - logging: { events: { ops: '*' } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.events.ops\\" has been deprecated and will be removed in 8.0. To access ops data moving forward, please enable debug logs for the \\"metrics.ops\\" context in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx", - ] - `); - }); - }); - - describe('logging.events.request and logging.events.response', () => { - it('warns when request and response events are used', () => { - const { messages } = applyCoreDeprecations({ - logging: { events: { request: '*', response: '*' } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.events.request\\" and \\"logging.events.response\\" have been deprecated and will be removed in 8.0. To access request and/or response data moving forward, please enable debug logs for the \\"http.server.response\\" context in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx", - ] - `); - }); - - it('warns when only request event is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { events: { request: '*' } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.events.request\\" and \\"logging.events.response\\" have been deprecated and will be removed in 8.0. To access request and/or response data moving forward, please enable debug logs for the \\"http.server.response\\" context in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx", - ] - `); - }); - - it('warns when only response event is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { events: { response: '*' } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.events.request\\" and \\"logging.events.response\\" have been deprecated and will be removed in 8.0. To access request and/or response data moving forward, please enable debug logs for the \\"http.server.response\\" context in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx", - ] - `); - }); - }); - - describe('logging.timezone', () => { - it('warns when ops events are used', () => { - const { messages } = applyCoreDeprecations({ - logging: { timezone: 'GMT' }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.timezone\\" has been deprecated and will be removed in 8.0. To set the timezone moving forward, please add a timezone date modifier to the log pattern in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx", - ] - `); - }); - }); - - describe('logging.dest', () => { - it('warns when dest is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { dest: 'stdout' }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.dest\\" has been deprecated and will be removed in 8.0. To set the destination moving forward, you can use the \\"console\\" appender in your logging configuration or define a custom one. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx", - ] - `); - }); - it('warns when dest path is given', () => { - const { messages } = applyCoreDeprecations({ - logging: { dest: '/log-log.txt' }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.dest\\" has been deprecated and will be removed in 8.0. To set the destination moving forward, you can use the \\"console\\" appender in your logging configuration or define a custom one. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx", - ] - `); - }); - }); - - describe('logging.quiet, logging.silent and logging.verbose', () => { - it('warns when quiet is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { quiet: true }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.quiet\\" has been deprecated and will be removed in 8.0. Moving forward, you can use \\"logging.root.level:error\\" in your logging configuration. ", - ] - `); - }); - it('warns when silent is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { silent: true }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.silent\\" has been deprecated and will be removed in 8.0. Moving forward, you can use \\"logging.root.level:off\\" in your logging configuration. ", - ] - `); - }); - it('warns when verbose is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { verbose: true }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.verbose\\" has been deprecated and will be removed in 8.0. Moving forward, you can use \\"logging.root.level:all\\" in your logging configuration. ", - ] - `); - }); - }); - - describe('logging.json', () => { - it('warns when json is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { json: true }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.json\\" has been deprecated and will be removed in 8.0. To specify log message format moving forward, you can configure the \\"appender.layout\\" property for every custom appender in your logging configuration. There is currently no default layout for custom appenders and each one must be declared explicitly. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx", - ] - `); - }); - }); - - describe('logging.rotate.enabled, logging.rotate.usePolling, logging.rotate.pollingInterval, logging.rotate.everyBytes and logging.rotate.keepFiles', () => { - it('warns when logging.rotate configurations are used', () => { - const { messages } = applyCoreDeprecations({ - logging: { rotate: { enabled: true } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.rotate\\" and sub-options have been deprecated and will be removed in 8.0. Moving forward, you can enable log rotation using the \\"rolling-file\\" appender for a logger in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#rolling-file-appender", - ] - `); - }); - - it('warns when logging.rotate polling configurations are used', () => { - const { messages } = applyCoreDeprecations({ - logging: { rotate: { enabled: true, usePolling: true, pollingInterval: 5000 } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.rotate\\" and sub-options have been deprecated and will be removed in 8.0. Moving forward, you can enable log rotation using the \\"rolling-file\\" appender for a logger in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#rolling-file-appender", - ] - `); - }); - - it('warns when logging.rotate.everyBytes configurations are used', () => { - const { messages } = applyCoreDeprecations({ - logging: { rotate: { enabled: true, everyBytes: 1048576 } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.rotate\\" and sub-options have been deprecated and will be removed in 8.0. Moving forward, you can enable log rotation using the \\"rolling-file\\" appender for a logger in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#rolling-file-appender", - ] - `); - }); - - it('warns when logging.rotate.keepFiles is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { rotate: { enabled: true, keepFiles: 1024 } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.rotate\\" and sub-options have been deprecated and will be removed in 8.0. Moving forward, you can enable log rotation using the \\"rolling-file\\" appender for a logger in your logging configuration. For more details, see https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#rolling-file-appender", - ] - `); - }); - }); - - describe('logging.events.log', () => { - it('warns when events.log is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { events: { log: ['info'] } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.events.log\\" has been deprecated and will be removed in 8.0. Moving forward, log levels can be customized on a per-logger basis using the new logging configuration.", - ] - `); - }); - }); - - describe('logging.events.error', () => { - it('warns when events.error is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { events: { error: ['some error'] } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.events.error\\" has been deprecated and will be removed in 8.0. Moving forward, you can use \\"logging.root.level: error\\" in your logging configuration.", - ] - `); - }); - }); - - describe('logging.filter', () => { - it('warns when filter.cookie is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { filter: { cookie: 'none' } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.filter\\" has been deprecated and will be removed in 8.0.", - ] - `); - }); - - it('warns when filter.authorization is used', () => { - const { messages } = applyCoreDeprecations({ - logging: { filter: { authorization: 'none' } }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"logging.filter\\" has been deprecated and will be removed in 8.0.", - ] - `); - }); - }); }); diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 6e7365d0d5cbf..4e5f711fe9f3a 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -8,19 +8,6 @@ import { ConfigDeprecationProvider, ConfigDeprecation } from '@kbn/config'; -const kibanaPathConf: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (process.env?.KIBANA_PATH_CONF) { - addDeprecation({ - message: `Environment variable "KIBANA_PATH_CONF" is deprecated. It has been replaced with "KBN_PATH_CONF" pointing to a config folder`, - correctiveActions: { - manualSteps: [ - 'Use "KBN_PATH_CONF" instead of "KIBANA_PATH_CONF" to point to a config folder.', - ], - }, - }); - } -}; - const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { if (settings.server?.basePath && !settings.server?.rewriteBasePath) { addDeprecation({ @@ -44,6 +31,7 @@ const rewriteCorsSettings: ConfigDeprecation = (settings, fromPath, addDeprecati if (typeof corsSettings === 'boolean') { addDeprecation({ message: '"server.cors" is deprecated and has been replaced by "server.cors.enabled"', + level: 'warning', correctiveActions: { manualSteps: [ `Replace "server.cors: ${corsSettings}" with "server.cors.enabled: ${corsSettings}"`, @@ -113,263 +101,8 @@ const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecati } }; -const opsLoggingEventDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.events?.ops) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingevents', - message: - '"logging.events.ops" has been deprecated and will be removed ' + - 'in 8.0. To access ops data moving forward, please enable debug logs for the ' + - '"metrics.ops" context in your logging configuration. For more details, see ' + - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx', - correctiveActions: { - manualSteps: [ - `Remove "logging.events.ops" from your kibana settings.`, - `Enable debug logs for the "metrics.ops" context in your logging configuration`, - ], - }, - }); - } -}; - -const requestLoggingEventDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.events?.request || settings.logging?.events?.response) { - const removeConfigsSteps = []; - - if (settings.logging?.events?.request) { - removeConfigsSteps.push(`Remove "logging.events.request" from your kibana configs.`); - } - - if (settings.logging?.events?.response) { - removeConfigsSteps.push(`Remove "logging.events.response" from your kibana configs.`); - } - - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingevents', - message: - '"logging.events.request" and "logging.events.response" have been deprecated and will be removed ' + - 'in 8.0. To access request and/or response data moving forward, please enable debug logs for the ' + - '"http.server.response" context in your logging configuration. For more details, see ' + - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx', - correctiveActions: { - manualSteps: [ - ...removeConfigsSteps, - `enable debug logs for the "http.server.response" context in your logging configuration.`, - ], - }, - }); - } -}; - -const timezoneLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.timezone) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingtimezone', - message: - '"logging.timezone" has been deprecated and will be removed ' + - 'in 8.0. To set the timezone moving forward, please add a timezone date modifier to the log pattern ' + - 'in your logging configuration. For more details, see ' + - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx', - correctiveActions: { - manualSteps: [ - `Remove "logging.timezone" from your kibana configs.`, - `To set the timezone add a timezone date modifier to the log pattern in your logging configuration.`, - ], - }, - }); - } -}; - -const destLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.dest) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingdest', - message: - '"logging.dest" has been deprecated and will be removed ' + - 'in 8.0. To set the destination moving forward, you can use the "console" appender ' + - 'in your logging configuration or define a custom one. For more details, see ' + - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx', - correctiveActions: { - manualSteps: [ - `Remove "logging.dest" from your kibana configs.`, - `To set the destination use the "console" appender in your logging configuration or define a custom one.`, - ], - }, - }); - } -}; - -const quietLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.quiet) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingquiet', - message: - '"logging.quiet" has been deprecated and will be removed ' + - 'in 8.0. Moving forward, you can use "logging.root.level:error" in your logging configuration. ', - correctiveActions: { - manualSteps: [ - `Remove "logging.quiet" from your kibana configs.`, - `Use "logging.root.level:error" in your logging configuration.`, - ], - }, - }); - } -}; - -const silentLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.silent) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingsilent', - message: - '"logging.silent" has been deprecated and will be removed ' + - 'in 8.0. Moving forward, you can use "logging.root.level:off" in your logging configuration. ', - correctiveActions: { - manualSteps: [ - `Remove "logging.silent" from your kibana configs.`, - `Use "logging.root.level:off" in your logging configuration.`, - ], - }, - }); - } -}; - -const verboseLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.verbose) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingverbose', - message: - '"logging.verbose" has been deprecated and will be removed ' + - 'in 8.0. Moving forward, you can use "logging.root.level:all" in your logging configuration. ', - correctiveActions: { - manualSteps: [ - `Remove "logging.verbose" from your kibana configs.`, - `Use "logging.root.level:all" in your logging configuration.`, - ], - }, - }); - } -}; - -const jsonLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - // We silence the deprecation warning when running in development mode because - // the dev CLI code in src/dev/cli_dev_mode/using_server_process.ts manually - // specifies `--logging.json=false`. Since it's executed in a child process, the - // ` legacyLoggingConfigSchema` returns `true` for the TTY check on `process.stdout.isTTY` - if (settings.logging?.json && settings.env !== 'development') { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx', - message: - '"logging.json" has been deprecated and will be removed ' + - 'in 8.0. To specify log message format moving forward, ' + - 'you can configure the "appender.layout" property for every custom appender in your logging configuration. ' + - 'There is currently no default layout for custom appenders and each one must be declared explicitly. ' + - 'For more details, see ' + - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx', - correctiveActions: { - manualSteps: [ - `Remove "logging.json" from your kibana configs.`, - `Configure the "appender.layout" property for every custom appender in your logging configuration.`, - ], - }, - }); - } -}; - -const logRotateDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.rotate) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#rolling-file-appender', - message: - '"logging.rotate" and sub-options have been deprecated and will be removed in 8.0. ' + - 'Moving forward, you can enable log rotation using the "rolling-file" appender for a logger ' + - 'in your logging configuration. For more details, see ' + - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#rolling-file-appender', - correctiveActions: { - manualSteps: [ - `Remove "logging.rotate" from your kibana configs.`, - `Enable log rotation using the "rolling-file" appender for a logger in your logging configuration.`, - ], - }, - }); - } -}; - -const logEventsLogDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.events?.log) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingevents', - message: - '"logging.events.log" has been deprecated and will be removed ' + - 'in 8.0. Moving forward, log levels can be customized on a per-logger basis using the new logging configuration.', - correctiveActions: { - manualSteps: [ - `Remove "logging.events.log" from your kibana configs.`, - `Customize log levels can be per-logger using the new logging configuration.`, - ], - }, - }); - } -}; - -const logEventsErrorDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.events?.error) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingevents', - message: - '"logging.events.error" has been deprecated and will be removed ' + - 'in 8.0. Moving forward, you can use "logging.root.level: error" in your logging configuration.', - correctiveActions: { - manualSteps: [ - `Remove "logging.events.error" from your kibana configs.`, - `Use "logging.root.level: error" in your logging configuration.`, - ], - }, - }); - } -}; - -const logFilterDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { - if (settings.logging?.filter) { - addDeprecation({ - documentationUrl: - 'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingfilter', - message: '"logging.filter" has been deprecated and will be removed in 8.0.', - correctiveActions: { - manualSteps: [`Remove "logging.filter" from your kibana configs.`], - }, - }); - } -}; - export const coreDeprecationProvider: ConfigDeprecationProvider = ({ rename, unusedFromRoot }) => [ - rename('cpu.cgroup.path.override', 'ops.cGroupOverrides.cpuPath'), - rename('cpuacct.cgroup.path.override', 'ops.cGroupOverrides.cpuAcctPath'), - rename('server.xsrf.whitelist', 'server.xsrf.allowlist'), rewriteCorsSettings, - kibanaPathConf, rewriteBasePathDeprecation, cspRulesDeprecation, - opsLoggingEventDeprecation, - requestLoggingEventDeprecation, - timezoneLoggingDeprecation, - destLoggingDeprecation, - quietLoggingDeprecation, - silentLoggingDeprecation, - verboseLoggingDeprecation, - jsonLoggingDeprecation, - logRotateDeprecation, - logEventsLogDeprecation, - logEventsErrorDeprecation, - logFilterDeprecation, ]; diff --git a/src/core/server/config/index.ts b/src/core/server/config/index.ts index 686564c6d678a..62e8ad755795f 100644 --- a/src/core/server/config/index.ts +++ b/src/core/server/config/index.ts @@ -25,10 +25,10 @@ export type { ConfigPath, CliArgs, ConfigDeprecation, + ConfigDeprecationContext, AddConfigDeprecation, ConfigDeprecationProvider, ConfigDeprecationFactory, EnvironmentMode, PackageInfo, - LegacyObjectToConfigAdapter, } from '@kbn/config'; diff --git a/src/core/server/config/integration_tests/config_deprecation.test.ts b/src/core/server/config/integration_tests/config_deprecation.test.ts index 0138c6e7ef154..5036fa4742b59 100644 --- a/src/core/server/config/integration_tests/config_deprecation.test.ts +++ b/src/core/server/config/integration_tests/config_deprecation.test.ts @@ -23,17 +23,13 @@ describe('configuration deprecations', () => { } }); - it('should not log deprecation warnings for default configuration that is not one of `logging.verbose`, `logging.quiet` or `logging.silent`', async () => { + it('should not log deprecation warnings for default configuration', async () => { root = kbnTestServer.createRoot(); await root.preboot(); await root.setup(); const logs = loggingSystemMock.collect(mockLoggingSystem); - expect(logs.warn.flat()).toMatchInlineSnapshot(` - Array [ - "\\"logging.silent\\" has been deprecated and will be removed in 8.0. Moving forward, you can use \\"logging.root.level:off\\" in your logging configuration. ", - ] - `); + expect(logs.warn.flat()).toHaveLength(0); }); }); diff --git a/src/core/server/config/mocks.ts b/src/core/server/config/mocks.ts index 46f6c3e95cefb..1ac4ea745aeec 100644 --- a/src/core/server/config/mocks.ts +++ b/src/core/server/config/mocks.ts @@ -11,6 +11,7 @@ import type { rawConfigServiceMock as rawConfigServiceMockTyped, configServiceMock as configServiceMockTyped, configMock as configMockTyped, + configDeprecationsMock as configDeprecationsMockTyped, } from '@kbn/config/target_types/mocks'; import { @@ -18,6 +19,7 @@ import { rawConfigServiceMock as rawConfigServiceMockNonTyped, configServiceMock as configServiceMockNonTyped, configMock as configMockNonTyped, + configDeprecationsMock as configDeprecationsMockNonTyped, // @ts-expect-error } from '@kbn/config/target_node/mocks'; @@ -25,5 +27,12 @@ const getEnvOptions: typeof getEnvOptionsTyped = getEnvOptionsNonTyped; const rawConfigServiceMock: typeof rawConfigServiceMockTyped = rawConfigServiceMockNonTyped; const configServiceMock: typeof configServiceMockTyped = configServiceMockNonTyped; const configMock: typeof configMockTyped = configMockNonTyped; +const configDeprecationsMock: typeof configDeprecationsMockTyped = configDeprecationsMockNonTyped; -export { getEnvOptions, rawConfigServiceMock, configServiceMock, configMock }; +export { + getEnvOptions, + rawConfigServiceMock, + configServiceMock, + configMock, + configDeprecationsMock, +}; diff --git a/src/core/server/config/test_utils.ts b/src/core/server/config/test_utils.ts index e3f9ca7eb29f2..f4d452005fbe4 100644 --- a/src/core/server/config/test_utils.ts +++ b/src/core/server/config/test_utils.ts @@ -6,28 +6,37 @@ * Side Public License, v 1. */ import { set } from '@elastic/safer-lodash-set'; -import type { ConfigDeprecationProvider } from '@kbn/config'; +import type { ConfigDeprecationProvider, ConfigDeprecationContext } from '@kbn/config'; import { configDeprecationFactory, applyDeprecations } from '@kbn/config'; +import { configDeprecationsMock } from './mocks'; + +const defaultContext = configDeprecationsMock.createContext(); function collectDeprecations( provider: ConfigDeprecationProvider, settings: Record, - path: string + path: string, + context: ConfigDeprecationContext = defaultContext ) { const deprecations = provider(configDeprecationFactory); const deprecationMessages: string[] = []; + const deprecationLevels: string[] = []; const { config: migrated } = applyDeprecations( settings, deprecations.map((deprecation) => ({ deprecation, path, + context, })), () => - ({ message }) => - deprecationMessages.push(message) + ({ message, level }) => { + deprecationMessages.push(message); + deprecationLevels.push(level ?? ''); + } ); return { messages: deprecationMessages, + levels: deprecationLevels, migrated, }; } diff --git a/src/core/server/deprecations/types.ts b/src/core/server/deprecations/types.ts index c924cacd02e28..7e276514a64d3 100644 --- a/src/core/server/deprecations/types.ts +++ b/src/core/server/deprecations/types.ts @@ -11,10 +11,16 @@ import type { IScopedClusterClient } from '../elasticsearch'; type MaybePromise = T | Promise; +/** + * @internal + */ export interface DomainDeprecationDetails extends DeprecationsDetails { domainId: string; } +/** + * @public + */ export interface DeprecationsDetails { /** * The title of the deprecation. @@ -43,11 +49,11 @@ export interface DeprecationsDetails { * across kibana deprecations. */ deprecationType?: 'config' | 'feature'; - /* (optional) link to the documentation for more details on the deprecation. */ + /** (optional) link to the documentation for more details on the deprecation. */ documentationUrl?: string; - /* (optional) specify the fix for this deprecation requires a full kibana restart. */ + /** (optional) specify the fix for this deprecation requires a full kibana restart. */ requireRestart?: boolean; - /* corrective action needed to fix this deprecation. */ + /** corrective action needed to fix this deprecation. */ correctiveActions: { /** * (optional) The api to be called to automatically fix the deprecation @@ -55,11 +61,11 @@ export interface DeprecationsDetails { * handle their deprecations. */ api?: { - /* Kibana route path. Passing a query string is allowed */ + /** Kibana route path. Passing a query string is allowed */ path: string; - /* Kibana route method: 'POST' or 'PUT'. */ + /** Kibana route method: 'POST' or 'PUT'. */ method: 'POST' | 'PUT'; - /* Additional details to be passed to the route. */ + /** Additional details to be passed to the route. */ body?: { [key: string]: any; }; @@ -74,15 +80,24 @@ export interface DeprecationsDetails { }; } +/** + * @public + */ export interface RegisterDeprecationsConfig { getDeprecations: (context: GetDeprecationsContext) => MaybePromise; } +/** + * @public + */ export interface GetDeprecationsContext { esClient: IScopedClusterClient; savedObjectsClient: SavedObjectsClientContract; } +/** + * @public + */ export interface DeprecationsGetResponse { deprecations: DomainDeprecationDetails[]; } diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 995b3ffbd947d..7470ff7081717 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -211,7 +211,7 @@ const deprecations: ConfigDeprecationProvider = () => [ }); } else if (es.logQueries === true) { addDeprecation({ - message: `Setting [${fromPath}.logQueries] is deprecated and no longer used. You should set the log level to "debug" for the "elasticsearch.queries" context in "logging.loggers" or use "logging.verbose: true".`, + message: `Setting [${fromPath}.logQueries] is deprecated and no longer used. You should set the log level to "debug" for the "elasticsearch.queries" context in "logging.loggers".`, correctiveActions: { manualSteps: [ `Remove Setting [${fromPath}.logQueries] from your kibana configs`, diff --git a/src/core/server/http/integration_tests/logging.test.ts b/src/core/server/http/integration_tests/logging.test.ts index 12d555a240cde..20e0175d4b19d 100644 --- a/src/core/server/http/integration_tests/logging.test.ts +++ b/src/core/server/http/integration_tests/logging.test.ts @@ -51,7 +51,6 @@ describe('request logging', () => { it('logs at the correct level and with the correct context', async () => { const root = kbnTestServer.createRoot({ logging: { - silent: true, appenders: { 'test-console': { type: 'console', @@ -99,7 +98,6 @@ describe('request logging', () => { let root: ReturnType; const config = { logging: { - silent: true, appenders: { 'test-console': { type: 'console', @@ -300,7 +298,6 @@ describe('request logging', () => { it('filters sensitive request headers when RewriteAppender is configured', async () => { root = kbnTestServer.createRoot({ logging: { - silent: true, appenders: { 'test-console': { type: 'console', @@ -402,7 +399,6 @@ describe('request logging', () => { it('filters sensitive response headers when RewriteAppender is configured', async () => { root = kbnTestServer.createRoot({ logging: { - silent: true, appenders: { 'test-console': { type: 'console', diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 110ac4d5bd973..2e46e8f68570c 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -96,6 +96,7 @@ export type { ConfigPath, ConfigService, ConfigDeprecation, + ConfigDeprecationContext, ConfigDeprecationProvider, ConfigDeprecationFactory, AddConfigDeprecation, diff --git a/src/core/server/legacy/integration_tests/logging.test.ts b/src/core/server/legacy/integration_tests/logging.test.ts deleted file mode 100644 index a79e434ce4576..0000000000000 --- a/src/core/server/legacy/integration_tests/logging.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { LegacyLoggingConfig } from '@kbn/config'; -import * as kbnTestServer from '../../../test_helpers/kbn_server'; - -import { - getPlatformLogsFromMock, - getLegacyPlatformLogsFromMock, -} from '../../logging/integration_tests/utils'; - -function createRoot(legacyLoggingConfig: LegacyLoggingConfig = {}) { - return kbnTestServer.createRoot({ - migrations: { skip: true }, // otherwise stuck in polling ES - plugins: { initialize: false }, - elasticsearch: { skipStartupConnectionCheck: true }, - logging: { - // legacy platform config - silent: false, - json: false, - ...legacyLoggingConfig, - events: { - log: ['test-file-legacy'], - }, - // platform config - appenders: { - 'test-console': { - type: 'console', - layout: { - highlight: false, - type: 'pattern', - }, - }, - }, - loggers: [ - { - name: 'test-file', - appenders: ['test-console'], - level: 'info', - }, - ], - }, - }); -} - -describe('logging service', () => { - let mockConsoleLog: jest.SpyInstance; - let mockStdout: jest.SpyInstance; - - beforeAll(async () => { - mockConsoleLog = jest.spyOn(global.console, 'log'); - mockStdout = jest.spyOn(global.process.stdout, 'write'); - }); - - afterAll(async () => { - mockConsoleLog.mockRestore(); - mockStdout.mockRestore(); - }); - - describe('compatibility', () => { - describe('uses configured loggers', () => { - let root: ReturnType; - beforeAll(async () => { - root = createRoot(); - - await root.preboot(); - await root.setup(); - await root.start(); - }, 30000); - - afterAll(async () => { - await root.shutdown(); - }); - - beforeEach(() => { - mockConsoleLog.mockClear(); - mockStdout.mockClear(); - }); - - it('when context matches', async () => { - root.logger.get('test-file').info('handled by NP'); - - expect(mockConsoleLog).toHaveBeenCalledTimes(1); - const loggedString = getPlatformLogsFromMock(mockConsoleLog); - expect(loggedString).toMatchInlineSnapshot(` - Array [ - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][INFO ][test-file] handled by NP", - ] - `); - }); - - it('falls back to the root legacy logger otherwise', async () => { - root.logger.get('test-file-legacy').info('handled by LP'); - - expect(mockStdout).toHaveBeenCalledTimes(1); - - const loggedString = getLegacyPlatformLogsFromMock(mockStdout); - expect(loggedString).toMatchInlineSnapshot(` - Array [ - " log [xx:xx:xx.xxx] [info][test-file-legacy] handled by LP - ", - ] - `); - }); - }); - - describe('logging config respects legacy logging settings', () => { - let root: ReturnType; - - afterEach(async () => { - mockConsoleLog.mockClear(); - mockStdout.mockClear(); - await root.shutdown(); - }); - - it('"silent": true', async () => { - root = createRoot({ silent: true }); - - await root.preboot(); - await root.setup(); - await root.start(); - - const platformLogger = root.logger.get('test-file'); - platformLogger.info('info'); - platformLogger.warn('warn'); - platformLogger.error('error'); - - expect(mockConsoleLog).toHaveBeenCalledTimes(3); - - expect(getPlatformLogsFromMock(mockConsoleLog)).toMatchInlineSnapshot(` - Array [ - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][INFO ][test-file] info", - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][WARN ][test-file] warn", - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][ERROR][test-file] error", - ] - `); - - mockStdout.mockClear(); - - const legacyPlatformLogger = root.logger.get('test-file-legacy'); - legacyPlatformLogger.info('info'); - legacyPlatformLogger.warn('warn'); - legacyPlatformLogger.error('error'); - - expect(mockStdout).toHaveBeenCalledTimes(0); - }); - - it('"quiet": true', async () => { - root = createRoot({ quiet: true }); - - await root.preboot(); - await root.setup(); - await root.start(); - - const platformLogger = root.logger.get('test-file'); - platformLogger.info('info'); - platformLogger.warn('warn'); - platformLogger.error('error'); - - expect(mockConsoleLog).toHaveBeenCalledTimes(3); - - expect(getPlatformLogsFromMock(mockConsoleLog)).toMatchInlineSnapshot(` - Array [ - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][INFO ][test-file] info", - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][WARN ][test-file] warn", - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][ERROR][test-file] error", - ] - `); - - mockStdout.mockClear(); - - const legacyPlatformLogger = root.logger.get('test-file-legacy'); - legacyPlatformLogger.info('info'); - legacyPlatformLogger.warn('warn'); - legacyPlatformLogger.error('error'); - - expect(mockStdout).toHaveBeenCalledTimes(1); - expect(getLegacyPlatformLogsFromMock(mockStdout)).toMatchInlineSnapshot(` - Array [ - " log [xx:xx:xx.xxx] [error][test-file-legacy] error - ", - ] - `); - }); - - it('"verbose": true', async () => { - root = createRoot({ verbose: true }); - - await root.preboot(); - await root.setup(); - await root.start(); - - const platformLogger = root.logger.get('test-file'); - platformLogger.info('info'); - platformLogger.warn('warn'); - platformLogger.error('error'); - - expect(mockConsoleLog).toHaveBeenCalledTimes(3); - - expect(getPlatformLogsFromMock(mockConsoleLog)).toMatchInlineSnapshot(` - Array [ - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][INFO ][test-file] info", - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][WARN ][test-file] warn", - "[xxxx-xx-xxTxx:xx:xx.xxx-xx:xx][ERROR][test-file] error", - ] - `); - - mockStdout.mockClear(); - - const legacyPlatformLogger = root.logger.get('test-file-legacy'); - legacyPlatformLogger.info('info'); - legacyPlatformLogger.warn('warn'); - legacyPlatformLogger.error('error'); - - expect(mockStdout).toHaveBeenCalledTimes(3); - expect(getLegacyPlatformLogsFromMock(mockStdout)).toMatchInlineSnapshot(` - Array [ - " log [xx:xx:xx.xxx] [info][test-file-legacy] info - ", - " log [xx:xx:xx.xxx] [warning][test-file-legacy] warn - ", - " log [xx:xx:xx.xxx] [error][test-file-legacy] error - ", - ] - `); - }); - }); - }); -}); diff --git a/src/core/server/legacy/legacy_service.mock.ts b/src/core/server/legacy/legacy_service.mock.ts deleted file mode 100644 index 0d72318a630e0..0000000000000 --- a/src/core/server/legacy/legacy_service.mock.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 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 { PublicMethodsOf } from '@kbn/utility-types'; -import { LegacyService } from './legacy_service'; - -type LegacyServiceMock = jest.Mocked>; - -const createLegacyServiceMock = (): LegacyServiceMock => ({ - setup: jest.fn(), - stop: jest.fn(), -}); - -export const legacyServiceMock = { - create: createLegacyServiceMock, -}; diff --git a/src/core/server/legacy/legacy_service.test.mocks.ts b/src/core/server/legacy/legacy_service.test.mocks.ts deleted file mode 100644 index 506f0fd6f96d3..0000000000000 --- a/src/core/server/legacy/legacy_service.test.mocks.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export const reconfigureLoggingMock = jest.fn(); -export const setupLoggingMock = jest.fn(); -export const setupLoggingRotateMock = jest.fn(); - -jest.doMock('@kbn/legacy-logging', () => ({ - ...(jest.requireActual('@kbn/legacy-logging') as any), - reconfigureLogging: reconfigureLoggingMock, - setupLogging: setupLoggingMock, - setupLoggingRotate: setupLoggingRotateMock, -})); diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts deleted file mode 100644 index 6b20bd7434baf..0000000000000 --- a/src/core/server/legacy/legacy_service.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { - setupLoggingMock, - setupLoggingRotateMock, - reconfigureLoggingMock, -} from './legacy_service.test.mocks'; - -import { BehaviorSubject } from 'rxjs'; -import moment from 'moment'; -import { REPO_ROOT } from '@kbn/dev-utils'; - -import { Config, Env, ObjectToConfigAdapter } from '../config'; - -import { getEnvOptions, configServiceMock } from '../config/mocks'; -import { loggingSystemMock } from '../logging/logging_system.mock'; -import { httpServiceMock } from '../http/http_service.mock'; -import { LegacyService, LegacyServiceSetupDeps } from './legacy_service'; - -let coreId: symbol; -let env: Env; -let config$: BehaviorSubject; - -let setupDeps: LegacyServiceSetupDeps; - -const logger = loggingSystemMock.create(); -let configService: ReturnType; - -beforeEach(() => { - coreId = Symbol(); - env = Env.createDefault(REPO_ROOT, getEnvOptions()); - configService = configServiceMock.create(); - - setupDeps = { - http: httpServiceMock.createInternalSetupContract(), - }; - - config$ = new BehaviorSubject( - new ObjectToConfigAdapter({ - elasticsearch: { hosts: ['http://127.0.0.1'] }, - server: { autoListen: true }, - }) - ); - - configService.getConfig$.mockReturnValue(config$); -}); - -afterEach(() => { - jest.clearAllMocks(); - setupLoggingMock.mockReset(); - setupLoggingRotateMock.mockReset(); - reconfigureLoggingMock.mockReset(); -}); - -describe('#setup', () => { - it('initializes legacy logging', async () => { - const opsConfig = { - interval: moment.duration(5, 'second'), - }; - const opsConfig$ = new BehaviorSubject(opsConfig); - - const loggingConfig = { - foo: 'bar', - }; - const loggingConfig$ = new BehaviorSubject(loggingConfig); - - configService.atPath.mockImplementation((path) => { - if (path === 'ops') { - return opsConfig$; - } - if (path === 'logging') { - return loggingConfig$; - } - return new BehaviorSubject({}); - }); - - const legacyService = new LegacyService({ - coreId, - env, - logger, - configService: configService as any, - }); - - await legacyService.setup(setupDeps); - - expect(setupLoggingMock).toHaveBeenCalledTimes(1); - expect(setupLoggingMock).toHaveBeenCalledWith( - setupDeps.http.server, - loggingConfig, - opsConfig.interval.asMilliseconds() - ); - - expect(setupLoggingRotateMock).toHaveBeenCalledTimes(1); - expect(setupLoggingRotateMock).toHaveBeenCalledWith(setupDeps.http.server, loggingConfig); - }); - - it('reloads the logging config when the config changes', async () => { - const opsConfig = { - interval: moment.duration(5, 'second'), - }; - const opsConfig$ = new BehaviorSubject(opsConfig); - - const loggingConfig = { - foo: 'bar', - }; - const loggingConfig$ = new BehaviorSubject(loggingConfig); - - configService.atPath.mockImplementation((path) => { - if (path === 'ops') { - return opsConfig$; - } - if (path === 'logging') { - return loggingConfig$; - } - return new BehaviorSubject({}); - }); - - const legacyService = new LegacyService({ - coreId, - env, - logger, - configService: configService as any, - }); - - await legacyService.setup(setupDeps); - - expect(reconfigureLoggingMock).toHaveBeenCalledTimes(1); - expect(reconfigureLoggingMock).toHaveBeenCalledWith( - setupDeps.http.server, - loggingConfig, - opsConfig.interval.asMilliseconds() - ); - - loggingConfig$.next({ - foo: 'changed', - }); - - expect(reconfigureLoggingMock).toHaveBeenCalledTimes(2); - expect(reconfigureLoggingMock).toHaveBeenCalledWith( - setupDeps.http.server, - { foo: 'changed' }, - opsConfig.interval.asMilliseconds() - ); - }); - - it('stops reloading logging config once the service is stopped', async () => { - const opsConfig = { - interval: moment.duration(5, 'second'), - }; - const opsConfig$ = new BehaviorSubject(opsConfig); - - const loggingConfig = { - foo: 'bar', - }; - const loggingConfig$ = new BehaviorSubject(loggingConfig); - - configService.atPath.mockImplementation((path) => { - if (path === 'ops') { - return opsConfig$; - } - if (path === 'logging') { - return loggingConfig$; - } - return new BehaviorSubject({}); - }); - - const legacyService = new LegacyService({ - coreId, - env, - logger, - configService: configService as any, - }); - - await legacyService.setup(setupDeps); - - expect(reconfigureLoggingMock).toHaveBeenCalledTimes(1); - expect(reconfigureLoggingMock).toHaveBeenCalledWith( - setupDeps.http.server, - loggingConfig, - opsConfig.interval.asMilliseconds() - ); - - await legacyService.stop(); - - loggingConfig$.next({ - foo: 'changed', - }); - - expect(reconfigureLoggingMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts deleted file mode 100644 index 1d5343ff5311d..0000000000000 --- a/src/core/server/legacy/legacy_service.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { combineLatest, Observable, Subscription } from 'rxjs'; -import { first } from 'rxjs/operators'; -import { Server } from '@hapi/hapi'; -import type { PublicMethodsOf } from '@kbn/utility-types'; -import { - reconfigureLogging, - setupLogging, - setupLoggingRotate, - LegacyLoggingConfig, -} from '@kbn/legacy-logging'; - -import { CoreContext } from '../core_context'; -import { config as loggingConfig } from '../logging'; -import { opsConfig, OpsConfigType } from '../metrics'; -import { Logger } from '../logging'; -import { InternalHttpServiceSetup } from '../http'; - -export interface LegacyServiceSetupDeps { - http: InternalHttpServiceSetup; -} - -/** @internal */ -export type ILegacyService = PublicMethodsOf; - -/** @internal */ -export class LegacyService { - private readonly log: Logger; - private readonly opsConfig$: Observable; - private readonly legacyLoggingConfig$: Observable; - private configSubscription?: Subscription; - - constructor(coreContext: CoreContext) { - const { logger, configService } = coreContext; - - this.log = logger.get('legacy-service'); - this.legacyLoggingConfig$ = configService.atPath(loggingConfig.path); - this.opsConfig$ = configService.atPath(opsConfig.path); - } - - public async setup(setupDeps: LegacyServiceSetupDeps) { - this.log.debug('setting up legacy service'); - await this.setupLegacyLogging(setupDeps.http.server); - } - - private async setupLegacyLogging(server: Server) { - const legacyLoggingConfig = await this.legacyLoggingConfig$.pipe(first()).toPromise(); - const currentOpsConfig = await this.opsConfig$.pipe(first()).toPromise(); - - await setupLogging(server, legacyLoggingConfig, currentOpsConfig.interval.asMilliseconds()); - await setupLoggingRotate(server, legacyLoggingConfig); - - this.configSubscription = combineLatest([this.legacyLoggingConfig$, this.opsConfig$]).subscribe( - ([newLoggingConfig, newOpsConfig]) => { - reconfigureLogging(server, newLoggingConfig, newOpsConfig.interval.asMilliseconds()); - } - ); - } - - public async stop() { - this.log.debug('stopping legacy service'); - - if (this.configSubscription !== undefined) { - this.configSubscription.unsubscribe(); - this.configSubscription = undefined; - } - } -} diff --git a/src/core/server/legacy/logging/appenders/__snapshots__/legacy_appender.test.ts.snap b/src/core/server/legacy/logging/appenders/__snapshots__/legacy_appender.test.ts.snap deleted file mode 100644 index 3c40362e8211e..0000000000000 --- a/src/core/server/legacy/logging/appenders/__snapshots__/legacy_appender.test.ts.snap +++ /dev/null @@ -1,142 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`\`append()\` correctly pushes records to legacy platform. 1`] = ` -Object { - "context": "context-1", - "level": LogLevel { - "id": "trace", - "value": 7, - }, - "message": "message-1", - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 2`] = ` -Object { - "context": "context-2", - "level": LogLevel { - "id": "debug", - "value": 6, - }, - "message": "message-2", - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 3`] = ` -Object { - "context": "context-3.sub-context-3", - "level": LogLevel { - "id": "info", - "value": 5, - }, - "message": "message-3", - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 4`] = ` -Object { - "context": "context-4.sub-context-4", - "level": LogLevel { - "id": "warn", - "value": 4, - }, - "message": "message-4", - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 5`] = ` -Object { - "context": "context-5", - "error": [Error: Some Error], - "level": LogLevel { - "id": "error", - "value": 3, - }, - "message": "message-5-with-error", - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 6`] = ` -Object { - "context": "context-6", - "level": LogLevel { - "id": "error", - "value": 3, - }, - "message": "message-6-with-message", - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 7`] = ` -Object { - "context": "context-7.sub-context-7.sub-sub-context-7", - "error": [Error: Some Fatal Error], - "level": LogLevel { - "id": "fatal", - "value": 2, - }, - "message": "message-7-with-error", - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 8`] = ` -Object { - "context": "context-8.sub-context-8.sub-sub-context-8", - "level": LogLevel { - "id": "fatal", - "value": 2, - }, - "message": "message-8-with-message", - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 9`] = ` -Object { - "context": "context-9.sub-context-9", - "level": LogLevel { - "id": "info", - "value": 5, - }, - "message": "message-9-with-message", - "meta": Object { - "someValue": 3, - }, - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; - -exports[`\`append()\` correctly pushes records to legacy platform. 10`] = ` -Object { - "context": "context-10.sub-context-10", - "level": LogLevel { - "id": "info", - "value": 5, - }, - "message": "message-10-with-message", - "meta": Object { - "tags": Array [ - "tag1", - "tag2", - ], - }, - "pid": Any, - "timestamp": 2012-02-01T11:22:33.044Z, -} -`; diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.test.ts b/src/core/server/legacy/logging/appenders/legacy_appender.test.ts deleted file mode 100644 index 9213403d72d07..0000000000000 --- a/src/core/server/legacy/logging/appenders/legacy_appender.test.ts +++ /dev/null @@ -1,135 +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. - */ - -jest.mock('@kbn/legacy-logging'); - -import { LogRecord, LogLevel } from '../../../logging'; -import { LegacyLoggingServer } from '@kbn/legacy-logging'; -import { LegacyAppender } from './legacy_appender'; - -afterEach(() => (LegacyLoggingServer as any).mockClear()); - -test('`configSchema` creates correct schema.', () => { - const appenderSchema = LegacyAppender.configSchema; - const validConfig = { type: 'legacy-appender', legacyLoggingConfig: { verbose: true } }; - expect(appenderSchema.validate(validConfig)).toEqual({ - type: 'legacy-appender', - legacyLoggingConfig: { verbose: true }, - }); - - const wrongConfig = { type: 'not-legacy-appender' }; - expect(() => appenderSchema.validate(wrongConfig)).toThrow(); -}); - -test('`append()` correctly pushes records to legacy platform.', () => { - const timestamp = new Date(Date.UTC(2012, 1, 1, 11, 22, 33, 44)); - const records: LogRecord[] = [ - { - context: 'context-1', - level: LogLevel.Trace, - message: 'message-1', - timestamp, - pid: 5355, - }, - { - context: 'context-2', - level: LogLevel.Debug, - message: 'message-2', - timestamp, - pid: 5355, - }, - { - context: 'context-3.sub-context-3', - level: LogLevel.Info, - message: 'message-3', - timestamp, - pid: 5355, - }, - { - context: 'context-4.sub-context-4', - level: LogLevel.Warn, - message: 'message-4', - timestamp, - pid: 5355, - }, - { - context: 'context-5', - error: new Error('Some Error'), - level: LogLevel.Error, - message: 'message-5-with-error', - timestamp, - pid: 5355, - }, - { - context: 'context-6', - level: LogLevel.Error, - message: 'message-6-with-message', - timestamp, - pid: 5355, - }, - { - context: 'context-7.sub-context-7.sub-sub-context-7', - error: new Error('Some Fatal Error'), - level: LogLevel.Fatal, - message: 'message-7-with-error', - timestamp, - pid: 5355, - }, - { - context: 'context-8.sub-context-8.sub-sub-context-8', - level: LogLevel.Fatal, - message: 'message-8-with-message', - timestamp, - pid: 5355, - }, - { - context: 'context-9.sub-context-9', - level: LogLevel.Info, - message: 'message-9-with-message', - timestamp, - pid: 5355, - meta: { someValue: 3 }, - }, - { - context: 'context-10.sub-context-10', - level: LogLevel.Info, - message: 'message-10-with-message', - timestamp, - pid: 5355, - meta: { tags: ['tag1', 'tag2'] }, - }, - ]; - - const appender = new LegacyAppender({ verbose: true }); - for (const record of records) { - appender.append(record); - } - - const [mockLegacyLoggingServerInstance] = (LegacyLoggingServer as any).mock.instances; - expect(mockLegacyLoggingServerInstance.log.mock.calls).toHaveLength(records.length); - records.forEach((r, idx) => { - expect(mockLegacyLoggingServerInstance.log.mock.calls[idx][0]).toMatchSnapshot({ - pid: expect.any(Number), - }); - }); -}); - -test('legacy logging server is correctly created and disposed.', async () => { - const mockRawLegacyLoggingConfig = { verbose: true }; - const appender = new LegacyAppender(mockRawLegacyLoggingConfig); - - expect(LegacyLoggingServer).toHaveBeenCalledTimes(1); - expect(LegacyLoggingServer).toHaveBeenCalledWith(mockRawLegacyLoggingConfig); - - const [mockLegacyLoggingServerInstance] = (LegacyLoggingServer as any).mock.instances; - expect(mockLegacyLoggingServerInstance.stop).not.toHaveBeenCalled(); - - await appender.dispose(); - - expect(mockLegacyLoggingServerInstance.stop).toHaveBeenCalledTimes(1); -}); diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.ts b/src/core/server/legacy/logging/appenders/legacy_appender.ts deleted file mode 100644 index 7e02d00c7b234..0000000000000 --- a/src/core/server/legacy/logging/appenders/legacy_appender.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { schema } from '@kbn/config-schema'; -import { LegacyLoggingServer } from '@kbn/legacy-logging'; -import { DisposableAppender, LogRecord } from '@kbn/logging'; - -export interface LegacyAppenderConfig { - type: 'legacy-appender'; - legacyLoggingConfig?: Record; -} - -/** - * Simple appender that just forwards `LogRecord` to the legacy KbnServer log. - * @internal - */ -export class LegacyAppender implements DisposableAppender { - public static configSchema = schema.object({ - type: schema.literal('legacy-appender'), - legacyLoggingConfig: schema.recordOf(schema.string(), schema.any()), - }); - - /** - * Sets {@link Appender.receiveAllLevels} because legacy does its own filtering based on the legacy logging - * configuration. - */ - public readonly receiveAllLevels = true; - - private readonly loggingServer: LegacyLoggingServer; - - constructor(legacyLoggingConfig: any) { - this.loggingServer = new LegacyLoggingServer(legacyLoggingConfig); - } - - /** - * Forwards `LogRecord` to the legacy platform that will layout and - * write record to the configured destination. - * @param record `LogRecord` instance to forward to. - */ - public append(record: LogRecord) { - this.loggingServer.log(record); - } - - public dispose() { - this.loggingServer.stop(); - } -} diff --git a/src/core/server/logging/README.mdx b/src/core/server/logging/README.mdx index 08e4ed34204c0..11437d1e8df20 100644 --- a/src/core/server/logging/README.mdx +++ b/src/core/server/logging/README.mdx @@ -562,11 +562,6 @@ The log will be less verbose with `warn` level for the `server` context name: ``` ### Logging config migration -Compatibility with the legacy logging system is assured until the end of the `v7` version. -All log messages handled by `root` context are forwarded to the legacy logging service using a `default` appender. If you re-write -root appenders, make sure that it contains `default` appender to provide backward compatibility. -**Note**: If you define an appender for a context name, the log messages for that specific context aren't handled by the -`root` context anymore and not forwarded to the legacy logging service. #### logging.dest By default logs in *stdout*. With new Kibana logging you can use pre-existing `console` appender or diff --git a/src/core/server/logging/appenders/appenders.test.ts b/src/core/server/logging/appenders/appenders.test.ts index bd32e4061049b..759fcb9546f09 100644 --- a/src/core/server/logging/appenders/appenders.test.ts +++ b/src/core/server/logging/appenders/appenders.test.ts @@ -9,7 +9,6 @@ import { mockCreateLayout } from './appenders.test.mocks'; import { ByteSizeValue } from '@kbn/config-schema'; -import { LegacyAppender } from '../../legacy/logging/appenders/legacy_appender'; import { Appenders } from './appenders'; import { ConsoleAppender } from './console/console_appender'; import { FileAppender } from './file/file_appender'; @@ -68,13 +67,6 @@ test('`create()` creates correct appender.', () => { }); expect(fileAppender).toBeInstanceOf(FileAppender); - const legacyAppender = Appenders.create({ - type: 'legacy-appender', - legacyLoggingConfig: { verbose: true }, - }); - - expect(legacyAppender).toBeInstanceOf(LegacyAppender); - const rollingFileAppender = Appenders.create({ type: 'rolling-file', fileName: 'path', diff --git a/src/core/server/logging/appenders/appenders.ts b/src/core/server/logging/appenders/appenders.ts index 88df355bd5ebe..3e867739aa1c7 100644 --- a/src/core/server/logging/appenders/appenders.ts +++ b/src/core/server/logging/appenders/appenders.ts @@ -10,10 +10,6 @@ import { schema } from '@kbn/config-schema'; import { assertNever } from '@kbn/std'; import { DisposableAppender } from '@kbn/logging'; -import { - LegacyAppender, - LegacyAppenderConfig, -} from '../../legacy/logging/appenders/legacy_appender'; import { Layouts } from '../layouts/layouts'; import { ConsoleAppender, ConsoleAppenderConfig } from './console/console_appender'; import { FileAppender, FileAppenderConfig } from './file/file_appender'; @@ -32,7 +28,6 @@ import { export const appendersSchema = schema.oneOf([ ConsoleAppender.configSchema, FileAppender.configSchema, - LegacyAppender.configSchema, RewriteAppender.configSchema, RollingFileAppender.configSchema, ]); @@ -41,7 +36,6 @@ export const appendersSchema = schema.oneOf([ export type AppenderConfigType = | ConsoleAppenderConfig | FileAppenderConfig - | LegacyAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; @@ -64,8 +58,6 @@ export class Appenders { return new RewriteAppender(config); case 'rolling-file': return new RollingFileAppender(config); - case 'legacy-appender': - return new LegacyAppender(config.legacyLoggingConfig); default: return assertNever(config); diff --git a/src/core/server/logging/integration_tests/logging.test.ts b/src/core/server/logging/integration_tests/logging.test.ts index ade10fc1c0257..ff681222c4f30 100644 --- a/src/core/server/logging/integration_tests/logging.test.ts +++ b/src/core/server/logging/integration_tests/logging.test.ts @@ -14,7 +14,6 @@ import { Subject } from 'rxjs'; function createRoot() { return kbnTestServer.createRoot({ logging: { - silent: true, // set "true" in kbnTestServer appenders: { 'test-console': { type: 'console', diff --git a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts index 83533e29ad12e..dc6a01b80e951 100644 --- a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts +++ b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts @@ -19,7 +19,6 @@ const flush = async () => delay(flushDelay); function createRoot(appenderConfig: any) { return kbnTestServer.createRoot({ logging: { - silent: true, // set "true" in kbnTestServer appenders: { 'rolling-file': appenderConfig, }, diff --git a/src/core/server/logging/logging_config.test.ts b/src/core/server/logging/logging_config.test.ts index e0004ba992c17..41acd072b295d 100644 --- a/src/core/server/logging/logging_config.test.ts +++ b/src/core/server/logging/logging_config.test.ts @@ -9,35 +9,18 @@ import { LoggingConfig, config } from './logging_config'; test('`schema` creates correct schema with defaults.', () => { - expect(config.schema.validate({})).toMatchInlineSnapshot( - { json: expect.any(Boolean) }, // default value depends on TTY - ` + expect(config.schema.validate({})).toMatchInlineSnapshot(` Object { "appenders": Map {}, - "dest": "stdout", - "events": Object {}, - "filter": Object {}, - "json": Any, "loggers": Array [], - "quiet": false, "root": Object { "appenders": Array [ "default", ], "level": "info", }, - "rotate": Object { - "enabled": false, - "everyBytes": 10485760, - "keepFiles": 7, - "pollingInterval": 10000, - "usePolling": false, - }, - "silent": false, - "verbose": false, } - ` - ); + `); }); test('`schema` throws if `root` logger does not have appenders configured.', () => { @@ -52,16 +35,14 @@ test('`schema` throws if `root` logger does not have appenders configured.', () ); }); -test('`schema` throws if `root` logger does not have "default" appender configured.', () => { +test('`schema` does not throw if `root` logger does not have "default" appender configured.', () => { expect(() => config.schema.validate({ root: { appenders: ['console'], }, }) - ).toThrowErrorMatchingInlineSnapshot( - `"[root]: \\"default\\" appender required for migration period till the next major release"` - ); + ).not.toThrow(); }); test('`getParentLoggerContext()` returns correct parent context name.', () => { diff --git a/src/core/server/logging/logging_config.ts b/src/core/server/logging/logging_config.ts index f5b75d7bb739c..a04506ad9c0f6 100644 --- a/src/core/server/logging/logging_config.ts +++ b/src/core/server/logging/logging_config.ts @@ -7,7 +7,6 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { legacyLoggingConfigSchema } from '@kbn/legacy-logging'; import { AppenderConfigType, Appenders } from './appenders/appenders'; // We need this helper for the types to be correct @@ -58,31 +57,23 @@ export const loggerSchema = schema.object({ /** @public */ export type LoggerConfigType = TypeOf; + export const config = { path: 'logging', - schema: legacyLoggingConfigSchema.extends({ + schema: schema.object({ appenders: schema.mapOf(schema.string(), Appenders.configSchema, { defaultValue: new Map(), }), loggers: schema.arrayOf(loggerSchema, { defaultValue: [], }), - root: schema.object( - { - appenders: schema.arrayOf(schema.string(), { - defaultValue: [DEFAULT_APPENDER_NAME], - minSize: 1, - }), - level: levelSchema, - }, - { - validate(rawConfig) { - if (!rawConfig.appenders.includes(DEFAULT_APPENDER_NAME)) { - return `"${DEFAULT_APPENDER_NAME}" appender required for migration period till the next major release`; - } - }, - } - ), + root: schema.object({ + appenders: schema.arrayOf(schema.string(), { + defaultValue: [DEFAULT_APPENDER_NAME], + minSize: 1, + }), + level: levelSchema, + }), }), }; diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index dd546d4e7eaca..ebe06326f499d 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -15,11 +15,6 @@ jest.mock('fs', () => ({ const dynamicProps = { process: { pid: expect.any(Number) } }; -jest.mock('@kbn/legacy-logging', () => ({ - ...(jest.requireActual('@kbn/legacy-logging') as any), - setupLoggingRotate: jest.fn().mockImplementation(() => Promise.resolve({})), -})); - const timestamp = new Date(Date.UTC(2012, 1, 1, 14, 33, 22, 11)); let mockConsoleLog: jest.SpyInstance; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index f8b56e81ab188..8b4dee45a8e72 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -39,7 +39,7 @@ import { deprecationsServiceMock } from './deprecations/deprecations_service.moc import { executionContextServiceMock } from './execution_context/execution_context_service.mock'; import { prebootServiceMock } from './preboot/preboot_service.mock'; -export { configServiceMock } from './config/mocks'; +export { configServiceMock, configDeprecationsMock } from './config/mocks'; export { httpServerMock } from './http/http_server.mocks'; export { httpResourcesMock } from './http_resources/http_resources_service.mock'; export { sessionStorageMock } from './http/cookie_session_storage.mocks'; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 1ef845730e1f3..c92f767ce891d 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -11,6 +11,7 @@ import { ByteSizeValue } from '@kbn/config-schema'; import { CliArgs } from '@kbn/config'; import { ClientOptions } from '@elastic/elasticsearch'; import { ConfigDeprecation } from '@kbn/config'; +import { ConfigDeprecationContext } from '@kbn/config'; import { ConfigDeprecationFactory } from '@kbn/config'; import { ConfigDeprecationProvider } from '@kbn/config'; import { ConfigPath } from '@kbn/config'; @@ -71,12 +72,11 @@ export interface AppCategory { // Warning: (ae-forgotten-export) The symbol "ConsoleAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "FileAppenderConfig" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "LegacyAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RewriteAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RollingFileAppenderConfig" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; +export type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; // @public @deprecated export interface AsyncPlugin { @@ -247,6 +247,8 @@ export const config: { export { ConfigDeprecation } +export { ConfigDeprecationContext } + export { ConfigDeprecationFactory } export { ConfigDeprecationProvider } @@ -801,11 +803,8 @@ export interface DeprecationsClient { getAllDeprecations: () => Promise; } -// Warning: (ae-missing-release-tag) "DeprecationsDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface DeprecationsDetails { - // (undocumented) correctiveActions: { api?: { path: string; @@ -817,11 +816,9 @@ export interface DeprecationsDetails { manualSteps: string[]; }; deprecationType?: 'config' | 'feature'; - // (undocumented) documentationUrl?: string; level: 'warning' | 'critical' | 'fetch_error'; message: string; - // (undocumented) requireRestart?: boolean; title: string; } @@ -984,8 +981,6 @@ export type GetAuthState = (request: KibanaRequest) => { state: T; }; -// Warning: (ae-missing-release-tag) "GetDeprecationsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface GetDeprecationsContext { // (undocumented) @@ -1700,8 +1695,6 @@ export type RedirectResponseOptions = HttpResponseOptions & { }; }; -// Warning: (ae-missing-release-tag) "RegisterDeprecationsConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface RegisterDeprecationsConfig { // Warning: (ae-forgotten-export) The symbol "MaybePromise" needs to be exported by the entry point index.d.ts diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index 47899043dc5a5..c4f420f75b5d1 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -7,32 +7,30 @@ */ import { httpServiceMock } from './http/http_service.mock'; + export const mockHttpService = httpServiceMock.create(); jest.doMock('./http/http_service', () => ({ HttpService: jest.fn(() => mockHttpService), })); import { pluginServiceMock } from './plugins/plugins_service.mock'; + export const mockPluginsService = pluginServiceMock.create(); jest.doMock('./plugins/plugins_service', () => ({ PluginsService: jest.fn(() => mockPluginsService), })); import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; + export const mockElasticsearchService = elasticsearchServiceMock.create(); jest.doMock('./elasticsearch/elasticsearch_service', () => ({ ElasticsearchService: jest.fn(() => mockElasticsearchService), })); -import { legacyServiceMock } from './legacy/legacy_service.mock'; -export const mockLegacyService = legacyServiceMock.create(); -jest.mock('./legacy/legacy_service', () => ({ - LegacyService: jest.fn(() => mockLegacyService), -})); - const realKbnConfig = jest.requireActual('@kbn/config'); import { configServiceMock } from './config/mocks'; + export const mockConfigService = configServiceMock.create(); jest.doMock('@kbn/config', () => ({ ...realKbnConfig, @@ -40,18 +38,21 @@ jest.doMock('@kbn/config', () => ({ })); import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; + export const mockSavedObjectsService = savedObjectsServiceMock.create(); jest.doMock('./saved_objects/saved_objects_service', () => ({ SavedObjectsService: jest.fn(() => mockSavedObjectsService), })); import { contextServiceMock } from './context/context_service.mock'; + export const mockContextService = contextServiceMock.create(); jest.doMock('./context/context_service', () => ({ ContextService: jest.fn(() => mockContextService), })); import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; + export const mockUiSettingsService = uiSettingsServiceMock.create(); jest.doMock('./ui_settings/ui_settings_service', () => ({ UiSettingsService: jest.fn(() => mockUiSettingsService), @@ -63,46 +64,54 @@ jest.doMock('./config/ensure_valid_configuration', () => ({ })); import { RenderingService, mockRenderingService } from './rendering/__mocks__/rendering_service'; + export { mockRenderingService }; jest.doMock('./rendering/rendering_service', () => ({ RenderingService })); import { environmentServiceMock } from './environment/environment_service.mock'; + export const mockEnvironmentService = environmentServiceMock.create(); jest.doMock('./environment/environment_service', () => ({ EnvironmentService: jest.fn(() => mockEnvironmentService), })); import { metricsServiceMock } from './metrics/metrics_service.mock'; + export const mockMetricsService = metricsServiceMock.create(); jest.doMock('./metrics/metrics_service', () => ({ MetricsService: jest.fn(() => mockMetricsService), })); import { statusServiceMock } from './status/status_service.mock'; + export const mockStatusService = statusServiceMock.create(); jest.doMock('./status/status_service', () => ({ StatusService: jest.fn(() => mockStatusService), })); import { loggingServiceMock } from './logging/logging_service.mock'; + export const mockLoggingService = loggingServiceMock.create(); jest.doMock('./logging/logging_service', () => ({ LoggingService: jest.fn(() => mockLoggingService), })); import { i18nServiceMock } from './i18n/i18n_service.mock'; + export const mockI18nService = i18nServiceMock.create(); jest.doMock('./i18n/i18n_service', () => ({ I18nService: jest.fn(() => mockI18nService), })); import { prebootServiceMock } from './preboot/preboot_service.mock'; + export const mockPrebootService = prebootServiceMock.create(); jest.doMock('./preboot/preboot_service', () => ({ PrebootService: jest.fn(() => mockPrebootService), })); import { deprecationsServiceMock } from './deprecations/deprecations_service.mock'; + export const mockDeprecationService = deprecationsServiceMock.create(); jest.doMock('./deprecations/deprecations_service', () => ({ DeprecationsService: jest.fn(() => mockDeprecationService), diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index b27c8fa769c48..112693aae0279 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -9,7 +9,6 @@ import { mockElasticsearchService, mockHttpService, - mockLegacyService, mockPluginsService, mockConfigService, mockSavedObjectsService, @@ -95,7 +94,6 @@ test('sets up services on "setup"', async () => { expect(mockHttpService.setup).not.toHaveBeenCalled(); expect(mockElasticsearchService.setup).not.toHaveBeenCalled(); expect(mockPluginsService.setup).not.toHaveBeenCalled(); - expect(mockLegacyService.setup).not.toHaveBeenCalled(); expect(mockSavedObjectsService.setup).not.toHaveBeenCalled(); expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); expect(mockRenderingService.setup).not.toHaveBeenCalled(); @@ -111,7 +109,6 @@ test('sets up services on "setup"', async () => { expect(mockHttpService.setup).toHaveBeenCalledTimes(1); expect(mockElasticsearchService.setup).toHaveBeenCalledTimes(1); expect(mockPluginsService.setup).toHaveBeenCalledTimes(1); - expect(mockLegacyService.setup).toHaveBeenCalledTimes(1); expect(mockSavedObjectsService.setup).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.setup).toHaveBeenCalledTimes(1); expect(mockRenderingService.setup).toHaveBeenCalledTimes(1); @@ -199,7 +196,6 @@ test('stops services on "stop"', async () => { expect(mockHttpService.stop).not.toHaveBeenCalled(); expect(mockElasticsearchService.stop).not.toHaveBeenCalled(); expect(mockPluginsService.stop).not.toHaveBeenCalled(); - expect(mockLegacyService.stop).not.toHaveBeenCalled(); expect(mockSavedObjectsService.stop).not.toHaveBeenCalled(); expect(mockUiSettingsService.stop).not.toHaveBeenCalled(); expect(mockMetricsService.stop).not.toHaveBeenCalled(); @@ -211,7 +207,6 @@ test('stops services on "stop"', async () => { expect(mockHttpService.stop).toHaveBeenCalledTimes(1); expect(mockElasticsearchService.stop).toHaveBeenCalledTimes(1); expect(mockPluginsService.stop).toHaveBeenCalledTimes(1); - expect(mockLegacyService.stop).toHaveBeenCalledTimes(1); expect(mockSavedObjectsService.stop).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.stop).toHaveBeenCalledTimes(1); expect(mockMetricsService.stop).toHaveBeenCalledTimes(1); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 867446484a230..8b0714e899139 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -21,7 +21,6 @@ import { ElasticsearchService } from './elasticsearch'; import { HttpService } from './http'; import { HttpResourcesService } from './http_resources'; import { RenderingService } from './rendering'; -import { LegacyService } from './legacy'; import { Logger, LoggerFactory, LoggingService, ILoggingSystem } from './logging'; import { UiSettingsService } from './ui_settings'; import { PluginsService, config as pluginsConfig } from './plugins'; @@ -69,7 +68,6 @@ export class Server { private readonly elasticsearch: ElasticsearchService; private readonly http: HttpService; private readonly rendering: RenderingService; - private readonly legacy: LegacyService; private readonly log: Logger; private readonly plugins: PluginsService; private readonly savedObjects: SavedObjectsService; @@ -108,7 +106,6 @@ export class Server { this.http = new HttpService(core); this.rendering = new RenderingService(core); this.plugins = new PluginsService(core); - this.legacy = new LegacyService(core); this.elasticsearch = new ElasticsearchService(core); this.savedObjects = new SavedObjectsService(core); this.uiSettings = new UiSettingsService(core); @@ -286,10 +283,6 @@ export class Server { const pluginsSetup = await this.plugins.setup(coreSetup); this.#pluginsInitialized = pluginsSetup.initialized; - await this.legacy.setup({ - http: httpSetup, - }); - this.registerCoreContext(coreSetup); this.coreApp.setup(coreSetup, uiPlugins); @@ -348,7 +341,6 @@ export class Server { public async stop() { 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(); diff --git a/src/core/server/status/routes/integration_tests/status.test.ts b/src/core/server/status/routes/integration_tests/status.test.ts index 082be62f8dc09..df840f5d7c059 100644 --- a/src/core/server/status/routes/integration_tests/status.test.ts +++ b/src/core/server/status/routes/integration_tests/status.test.ts @@ -18,20 +18,30 @@ import { MetricsServiceSetup } from '../../../metrics'; import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { registerStatusRoute } from '../status'; -import { ServiceStatus, ServiceStatusLevels } from '../../types'; +import { ServiceStatus, ServiceStatusLevels, ServiceStatusLevel } from '../../types'; import { statusServiceMock } from '../../status_service.mock'; import { executionContextServiceMock } from '../../../execution_context/execution_context_service.mock'; import { contextServiceMock } from '../../../context/context_service.mock'; const coreId = Symbol('core'); +const createServiceStatus = ( + level: ServiceStatusLevel = ServiceStatusLevels.available +): ServiceStatus => ({ + level, + summary: 'status summary', +}); + describe('GET /api/status', () => { let server: HttpService; let httpSetup: InternalHttpServiceSetup; let metrics: jest.Mocked; let incrementUsageCounter: jest.Mock; - const setupServer = async ({ allowAnonymous = true }: { allowAnonymous?: boolean } = {}) => { + const setupServer = async ({ + allowAnonymous = true, + coreOverall, + }: { allowAnonymous?: boolean; coreOverall?: ServiceStatus } = {}) => { const coreContext = createCoreContext({ coreId }); const contextService = new ContextService(coreContext); @@ -43,7 +53,12 @@ describe('GET /api/status', () => { }); metrics = metricsServiceMock.createSetupContract(); - const status = statusServiceMock.createSetupContract(); + + const status = statusServiceMock.createInternalSetupContract(); + if (coreOverall) { + status.coreOverall$ = new BehaviorSubject(coreOverall); + } + const pluginsStatus$ = new BehaviorSubject>({ a: { level: ServiceStatusLevels.available, summary: 'a is available' }, b: { level: ServiceStatusLevels.degraded, summary: 'b is degraded' }, @@ -71,6 +86,7 @@ describe('GET /api/status', () => { metrics, status: { overall$: status.overall$, + coreOverall$: status.coreOverall$, core$: status.core$, plugins$: pluginsStatus$, }, @@ -318,4 +334,60 @@ describe('GET /api/status', () => { expect(incrementUsageCounter).not.toHaveBeenCalled(); }); }); + + describe('status level and http response code', () => { + describe('using standard format', () => { + it('respond with a 200 when core.overall.status is available', async () => { + await setupServer({ + coreOverall: createServiceStatus(ServiceStatusLevels.available), + }); + await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(200); + }); + it('respond with a 200 when core.overall.status is degraded', async () => { + await setupServer({ + coreOverall: createServiceStatus(ServiceStatusLevels.degraded), + }); + await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(200); + }); + it('respond with a 503 when core.overall.status is unavailable', async () => { + await setupServer({ + coreOverall: createServiceStatus(ServiceStatusLevels.unavailable), + }); + await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(503); + }); + it('respond with a 503 when core.overall.status is critical', async () => { + await setupServer({ + coreOverall: createServiceStatus(ServiceStatusLevels.critical), + }); + await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(503); + }); + }); + + describe('using legacy format', () => { + it('respond with a 200 when core.overall.status is available', async () => { + await setupServer({ + coreOverall: createServiceStatus(ServiceStatusLevels.available), + }); + await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(200); + }); + it('respond with a 200 when core.overall.status is degraded', async () => { + await setupServer({ + coreOverall: createServiceStatus(ServiceStatusLevels.degraded), + }); + await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(200); + }); + it('respond with a 503 when core.overall.status is unavailable', async () => { + await setupServer({ + coreOverall: createServiceStatus(ServiceStatusLevels.unavailable), + }); + await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(503); + }); + it('respond with a 503 when core.overall.status is critical', async () => { + await setupServer({ + coreOverall: createServiceStatus(ServiceStatusLevels.critical), + }); + await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(503); + }); + }); + }); }); diff --git a/src/core/server/status/routes/status.ts b/src/core/server/status/routes/status.ts index cef5ee05ea2e5..7751980ae6ec3 100644 --- a/src/core/server/status/routes/status.ts +++ b/src/core/server/status/routes/status.ts @@ -31,6 +31,7 @@ interface Deps { }; metrics: MetricsServiceSetup; status: { + coreOverall$: Observable; overall$: Observable; core$: Observable; plugins$: Observable>; @@ -59,9 +60,11 @@ export const registerStatusRoute = ({ // Since the status.plugins$ observable is not subscribed to elsewhere, we need to subscribe it here to eagerly load // the plugins status when Kibana starts up so this endpoint responds quickly on first boot. const combinedStatus$ = new ReplaySubject< - [ServiceStatus, CoreStatus, Record>] + [ServiceStatus, ServiceStatus, CoreStatus, Record>] >(1); - combineLatest([status.overall$, status.core$, status.plugins$]).subscribe(combinedStatus$); + combineLatest([status.overall$, status.coreOverall$, status.core$, status.plugins$]).subscribe( + combinedStatus$ + ); router.get( { @@ -89,7 +92,7 @@ export const registerStatusRoute = ({ async (context, req, res) => { const { version, buildSha, buildNum } = config.packageInfo; const versionWithoutSnapshot = version.replace(SNAPSHOT_POSTFIX, ''); - const [overall, core, plugins] = await combinedStatus$.pipe(first()).toPromise(); + const [overall, coreOverall, core, plugins] = await combinedStatus$.pipe(first()).toPromise(); const { v8format = true, v7format = false } = req.query ?? {}; @@ -137,7 +140,7 @@ export const registerStatusRoute = ({ }, }; - const statusCode = overall.level >= ServiceStatusLevels.unavailable ? 503 : 200; + const statusCode = coreOverall.level >= ServiceStatusLevels.unavailable ? 503 : 200; return res.custom({ body, statusCode, bypassErrorFormat: true }); } ); diff --git a/src/core/server/status/status_service.mock.ts b/src/core/server/status/status_service.mock.ts index 8ef34558ca7b2..7241bb2f0479e 100644 --- a/src/core/server/status/status_service.mock.ts +++ b/src/core/server/status/status_service.mock.ts @@ -42,6 +42,7 @@ const createSetupContractMock = () => { const createInternalSetupContractMock = () => { const setupContract: jest.Mocked = { core$: new BehaviorSubject(availableCoreStatus), + coreOverall$: new BehaviorSubject(available), overall$: new BehaviorSubject(available), isStatusPageAnonymous: jest.fn().mockReturnValue(false), plugins: { diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index 9148f69e079aa..255ed821bc2fe 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -30,6 +30,7 @@ describe('StatusService', () => { }); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const available: ServiceStatus = { level: ServiceStatusLevels.available, summary: 'Available', @@ -38,6 +39,10 @@ describe('StatusService', () => { level: ServiceStatusLevels.degraded, summary: 'This is degraded!', }; + const critical: ServiceStatus = { + level: ServiceStatusLevels.critical, + summary: 'This is critical!', + }; type SetupDeps = Parameters[0]; const setupDeps = (overrides: Partial): SetupDeps => { @@ -321,6 +326,177 @@ describe('StatusService', () => { }); }); + describe('coreOverall$', () => { + it('exposes an overall summary of core services', async () => { + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: of(degraded), + }, + savedObjects: { + status$: of(degraded), + }, + }) + ); + expect(await setup.coreOverall$.pipe(first()).toPromise()).toMatchObject({ + level: ServiceStatusLevels.degraded, + summary: '[2] services are degraded', + }); + }); + + it('computes the summary depending on the services status', async () => { + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: of(degraded), + }, + savedObjects: { + status$: of(critical), + }, + }) + ); + expect(await setup.coreOverall$.pipe(first()).toPromise()).toMatchObject({ + level: ServiceStatusLevels.critical, + summary: '[savedObjects]: This is critical!', + }); + }); + + it('replays last event', async () => { + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: of(degraded), + }, + savedObjects: { + status$: of(degraded), + }, + }) + ); + + const subResult1 = await setup.coreOverall$.pipe(first()).toPromise(); + const subResult2 = await setup.coreOverall$.pipe(first()).toPromise(); + const subResult3 = await setup.coreOverall$.pipe(first()).toPromise(); + + expect(subResult1).toMatchObject({ + level: ServiceStatusLevels.degraded, + summary: '[2] services are degraded', + }); + expect(subResult2).toMatchObject({ + level: ServiceStatusLevels.degraded, + summary: '[2] services are degraded', + }); + expect(subResult3).toMatchObject({ + level: ServiceStatusLevels.degraded, + summary: '[2] services are degraded', + }); + }); + + it('does not emit duplicate events', async () => { + const elasticsearch$ = new BehaviorSubject(available); + const savedObjects$ = new BehaviorSubject(degraded); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: elasticsearch$, + }, + savedObjects: { + status$: savedObjects$, + }, + }) + ); + + const statusUpdates: ServiceStatus[] = []; + const subscription = setup.coreOverall$.subscribe((status) => statusUpdates.push(status)); + + // Wait for timers to ensure that duplicate events are still filtered out regardless of debouncing. + elasticsearch$.next(available); + await delay(500); + elasticsearch$.next(available); + await delay(500); + elasticsearch$.next({ + level: ServiceStatusLevels.available, + summary: `Wow another summary`, + }); + await delay(500); + savedObjects$.next(degraded); + await delay(500); + savedObjects$.next(available); + await delay(500); + savedObjects$.next(available); + await delay(500); + subscription.unsubscribe(); + + expect(statusUpdates).toMatchInlineSnapshot(` + Array [ + Object { + "detail": "See the status page for more information", + "level": degraded, + "meta": Object { + "affectedServices": Array [ + "savedObjects", + ], + }, + "summary": "[savedObjects]: This is degraded!", + }, + Object { + "level": available, + "summary": "All services are available", + }, + ] + `); + }); + + it('debounces events in quick succession', async () => { + const savedObjects$ = new BehaviorSubject(available); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: new BehaviorSubject(available), + }, + savedObjects: { + status$: savedObjects$, + }, + }) + ); + + const statusUpdates: ServiceStatus[] = []; + const subscription = setup.coreOverall$.subscribe((status) => statusUpdates.push(status)); + + // All of these should debounced into a single `available` status + savedObjects$.next(degraded); + savedObjects$.next(available); + savedObjects$.next(degraded); + savedObjects$.next(available); + savedObjects$.next(degraded); + savedObjects$.next(available); + savedObjects$.next(degraded); + // Waiting for the debounce timeout should cut a new update + await delay(500); + savedObjects$.next(available); + await delay(500); + subscription.unsubscribe(); + + expect(statusUpdates).toMatchInlineSnapshot(` + Array [ + Object { + "detail": "See the status page for more information", + "level": degraded, + "meta": Object { + "affectedServices": Array [ + "savedObjects", + ], + }, + "summary": "[savedObjects]: This is degraded!", + }, + Object { + "level": available, + "summary": "All services are available", + }, + ] + `); + }); + }); + describe('preboot status routes', () => { let prebootRouterMock: RouterMock; beforeEach(async () => { diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 107074bdb98b1..a0ac5b392efe1 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -49,7 +49,7 @@ export class StatusService implements CoreService { private overall$?: Observable; private pluginsStatus?: PluginsStatusService; - private overallSubscription?: Subscription; + private subscriptions: Subscription[] = []; constructor(private readonly coreContext: CoreContext) { this.logger = coreContext.logger.get('status'); @@ -88,8 +88,24 @@ export class StatusService implements CoreService { shareReplay(1) ); - // Create an unused subscription to ensure all underlying lazy observables are started. - this.overallSubscription = this.overall$.subscribe(); + const coreOverall$ = core$.pipe( + // Prevent many emissions at once from dependency status resolution from making this too noisy + debounceTime(25), + map((coreStatus) => { + const coreOverall = getSummaryStatus([...Object.entries(coreStatus)]); + this.logger.debug(`Recalculated core overall status`, { + kibana: { + status: coreOverall, + }, + }); + return coreOverall; + }), + distinctUntilChanged(isDeepStrictEqual), + shareReplay(1) + ); + + // Create unused subscriptions to ensure all underlying lazy observables are started. + this.subscriptions.push(this.overall$.subscribe(), coreOverall$.subscribe()); const commonRouteDeps = { config: { @@ -103,6 +119,7 @@ export class StatusService implements CoreService { overall$: this.overall$, plugins$: this.pluginsStatus.getAll$(), core$, + coreOverall$, }, incrementUsageCounter: coreUsageData.incrementUsageCounter, }; @@ -128,6 +145,7 @@ export class StatusService implements CoreService { return { core$, + coreOverall$, overall$: this.overall$, plugins: { set: this.pluginsStatus.set.bind(this.pluginsStatus), @@ -153,10 +171,10 @@ export class StatusService implements CoreService { this.stop$.next(); this.stop$.complete(); - if (this.overallSubscription) { - this.overallSubscription.unsubscribe(); - this.overallSubscription = undefined; - } + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + this.subscriptions = []; } private setupCoreStatus({ diff --git a/src/core/server/status/types.ts b/src/core/server/status/types.ts index bfca4c74d9365..aab3bf302dfea 100644 --- a/src/core/server/status/types.ts +++ b/src/core/server/status/types.ts @@ -232,6 +232,11 @@ export interface StatusServiceSetup { /** @internal */ export interface InternalStatusServiceSetup extends Pick { + /** + * Overall status of core's service. + */ + coreOverall$: Observable; + // Namespaced under `plugins` key to improve clarity that these are APIs for plugins specifically. plugins: { set(plugin: PluginName, status$: Observable): void; diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 67bd6c7455d6d..58720be637e2f 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -32,7 +32,11 @@ const DEFAULTS_SETTINGS = { port: 0, xsrf: { disableProtection: true }, }, - logging: { silent: true }, + logging: { + root: { + level: 'off', + }, + }, plugins: {}, migrations: { skip: false }, }; @@ -45,7 +49,6 @@ export function createRootWithSettings( configs: [], cliArgs: { dev: false, - silent: false, watch: false, basePath: false, runExamples: false, diff --git a/src/core/types/elasticsearch/search.ts b/src/core/types/elasticsearch/search.ts index 88d6cda3777dd..a54f5f3758ce3 100644 --- a/src/core/types/elasticsearch/search.ts +++ b/src/core/types/elasticsearch/search.ts @@ -48,7 +48,7 @@ type ValueTypeOfField = T extends Record type MaybeArray = T | T[]; -type Fields = Exclude['body']['fields'], undefined>; +type Fields = Required['body']>['fields']; type DocValueFields = MaybeArray; export type SearchHit< 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 cee43fd85c90f..dd5b66af9ef21 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 @@ -26,8 +26,6 @@ kibana_vars=( console.enabled console.proxyConfig console.proxyFilter - cpu.cgroup.path.override - cpuacct.cgroup.path.override csp.rules csp.strict csp.warnLegacyBrowsers @@ -82,24 +80,13 @@ kibana_vars=( logging.appenders logging.appenders.console logging.appenders.file - logging.dest - logging.json logging.loggers logging.loggers.appenders logging.loggers.level logging.loggers.name - logging.quiet logging.root logging.root.appenders logging.root.level - logging.rotate.enabled - logging.rotate.everyBytes - logging.rotate.keepFiles - logging.rotate.pollingInterval - logging.rotate.usePolling - logging.silent - logging.useUTC - logging.verbose map.includeElasticMapsService map.proxyElasticMapsServiceInMaps map.regionmap @@ -186,7 +173,6 @@ kibana_vars=( server.uuid server.xsrf.allowlist server.xsrf.disableProtection - server.xsrf.whitelist status.allowAnonymous status.v6ApiFormat telemetry.allowChangingOptInStatus @@ -205,7 +191,6 @@ kibana_vars=( vis_type_vega.enableExternalUrls xpack.actions.allowedHosts xpack.actions.customHostSettings - xpack.actions.enabled xpack.actions.enabledActionTypes xpack.actions.maxResponseContentLength xpack.actions.preconfigured @@ -222,6 +207,7 @@ kibana_vars=( xpack.alerting.healthCheck.interval xpack.alerting.invalidateApiKeysTask.interval xpack.alerting.invalidateApiKeysTask.removalDelay + xpack.alerting.defaultRuleTaskTimeout xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval xpack.alerts.invalidateApiKeysTask.removalDelay @@ -262,7 +248,6 @@ kibana_vars=( xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled xpack.encryptedSavedObjects.encryptionKey xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys - xpack.event_log.enabled xpack.event_log.indexEntries xpack.event_log.logEntries xpack.fleet.agentPolicies @@ -329,7 +314,6 @@ kibana_vars=( xpack.reporting.csv.useByteOrderMarkEncoding xpack.reporting.enabled xpack.reporting.encryptionKey - xpack.reporting.index xpack.reporting.kibanaApp xpack.reporting.kibanaServer.hostname xpack.reporting.kibanaServer.port @@ -395,7 +379,6 @@ kibana_vars=( xpack.securitySolution.prebuiltRulesFromSavedObjects xpack.spaces.enabled xpack.spaces.maxSpaces - xpack.task_manager.enabled xpack.task_manager.index xpack.task_manager.max_attempts xpack.task_manager.max_poll_inactivity_cycles @@ -437,7 +420,7 @@ umask 0002 # paths. Therefore, Kibana provides a mechanism to override # reading the cgroup path from /proc/self/cgroup and instead uses the # cgroup path defined the configuration properties -# cpu.cgroup.path.override and cpuacct.cgroup.path.override. +# ops.cGroupOverrides.cpuPath and ops.cGroupOverrides.cpuAcctPath. # Therefore, we set this value here so that cgroup statistics are # available for the container this process will run in. diff --git a/src/dev/run_check_published_api_changes.ts b/src/dev/run_check_published_api_changes.ts index 7c8105bc40c51..452922ac56bcd 100644 --- a/src/dev/run_check_published_api_changes.ts +++ b/src/dev/run_check_published_api_changes.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog, getTimeReporter } from '@kbn/dev-utils'; import { Extractor, IConfigFile, @@ -27,6 +27,9 @@ const log = new ToolingLog({ writeTo: process.stdout, }); +const runStartTime = Date.now(); +const reportTime = getTimeReporter(log, 'scripts/check_published_api_changes'); + /* * Step 1: execute build:types * This users tsconfig.types.json to generate types in `target/types` @@ -184,6 +187,7 @@ async function run(folder: string, { opts }: { opts: Options }): Promise { + reportTime(runStartTime, 'error', { + success: false, + error: e.message, + }); log.error(e); process.exitCode = 1; }); diff --git a/src/dev/run_i18n_check.ts b/src/dev/run_i18n_check.ts index 48ce2e013fc29..8aa93d33f60fd 100644 --- a/src/dev/run_i18n_check.ts +++ b/src/dev/run_i18n_check.ts @@ -9,7 +9,7 @@ import chalk from 'chalk'; import Listr from 'listr'; -import { createFailError, run } from '@kbn/dev-utils'; +import { createFailError, run, ToolingLog, getTimeReporter } from '@kbn/dev-utils'; import { ErrorReporter, I18nConfig } from './i18n'; import { extractDefaultMessages, @@ -19,6 +19,14 @@ import { mergeConfigs, } from './i18n/tasks'; +const toolingLog = new ToolingLog({ + level: 'info', + writeTo: process.stdout, +}); + +const runStartTime = Date.now(); +const reportTime = getTimeReporter(toolingLog, 'scripts/i18n_check'); + const skipOnNoTranslations = ({ config }: { config: I18nConfig }) => !config.translations.length && 'No translations found.'; @@ -116,13 +124,24 @@ run( const reporter = new ErrorReporter(); const messages: Map = new Map(); await list.run({ messages, reporter }); - } catch (error) { + + reportTime(runStartTime, 'total', { + success: true, + }); + } catch (error: Error | ErrorReporter) { process.exitCode = 1; if (error instanceof ErrorReporter) { error.errors.forEach((e: string | Error) => log.error(e)); + reportTime(runStartTime, 'error', { + success: false, + }); } else { log.error('Unhandled exception!'); log.error(error); + reportTime(runStartTime, 'error', { + success: false, + error: error.message, + }); } } }, diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index a61a2618d6428..c04f0d4f9320f 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -12,6 +12,7 @@ export const storybookAliases = { canvas: 'x-pack/plugins/canvas/storybook', codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', ci_composite: '.ci/.storybook', + custom_integrations: 'src/plugins/custom_integrations/storybook', url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook', dashboard: 'src/plugins/dashboard/.storybook', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', diff --git a/src/plugins/custom_integrations/common/index.ts b/src/plugins/custom_integrations/common/index.ts index 73e15c91ce4bf..e2408d3124604 100755 --- a/src/plugins/custom_integrations/common/index.ts +++ b/src/plugins/custom_integrations/common/index.ts @@ -15,6 +15,7 @@ export interface IntegrationCategoryCount { } export const INTEGRATION_CATEGORY_DISPLAY = { + // Known EPR aws: 'AWS', azure: 'Azure', cloud: 'Cloud', @@ -39,8 +40,12 @@ export const INTEGRATION_CATEGORY_DISPLAY = { ticketing: 'Ticketing', version_control: 'Version control', web: 'Web', + + // Kibana added upload_file: 'Upload a file', + language_client: 'Language client', + // Internal updates_available: 'Updates available', }; diff --git a/src/plugins/custom_integrations/kibana.json b/src/plugins/custom_integrations/kibana.json index 3a78270d9ef09..cd58c1aec1ecb 100755 --- a/src/plugins/custom_integrations/kibana.json +++ b/src/plugins/custom_integrations/kibana.json @@ -12,5 +12,8 @@ "extraPublicDirs": [ "common" ], + "requiredPlugins": [ + "presentationUtil" + ], "optionalPlugins": [] } diff --git a/src/plugins/custom_integrations/public/assets/language_clients/dotnet.svg b/src/plugins/custom_integrations/public/assets/language_clients/dotnet.svg new file mode 100755 index 0000000000000..92a7ad45d9f9c --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/dotnet.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/es.svg b/src/plugins/custom_integrations/public/assets/language_clients/es.svg new file mode 100755 index 0000000000000..b1224e212e098 --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/es.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/go.svg b/src/plugins/custom_integrations/public/assets/language_clients/go.svg new file mode 100755 index 0000000000000..223a57194fd7c --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/go.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/java.svg b/src/plugins/custom_integrations/public/assets/language_clients/java.svg new file mode 100644 index 0000000000000..d24d844695762 --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/java.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/nodejs.svg b/src/plugins/custom_integrations/public/assets/language_clients/nodejs.svg new file mode 100755 index 0000000000000..4dd358743bbff --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/nodejs.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/perl.svg b/src/plugins/custom_integrations/public/assets/language_clients/perl.svg new file mode 100755 index 0000000000000..6ef322a3f58ae --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/perl.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/php.svg b/src/plugins/custom_integrations/public/assets/language_clients/php.svg new file mode 100755 index 0000000000000..7a1c20116f466 --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/php.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/python.svg b/src/plugins/custom_integrations/public/assets/language_clients/python.svg new file mode 100755 index 0000000000000..b7234c439ced5 --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/python.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/ruby.svg b/src/plugins/custom_integrations/public/assets/language_clients/ruby.svg new file mode 100755 index 0000000000000..5e515bc0dd98e --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/ruby.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/custom_integrations/public/assets/language_clients/rust.svg b/src/plugins/custom_integrations/public/assets/language_clients/rust.svg new file mode 100755 index 0000000000000..82dcaf2ade93e --- /dev/null +++ b/src/plugins/custom_integrations/public/assets/language_clients/rust.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/plugins/custom_integrations/public/components/index.tsx b/src/plugins/custom_integrations/public/components/index.tsx new file mode 100644 index 0000000000000..cfbec7d6d5ae5 --- /dev/null +++ b/src/plugins/custom_integrations/public/components/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { Suspense, ComponentType, ReactElement, Ref } from 'react'; +import { EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui'; + +/** + * A HOC which supplies React.Suspense with a fallback component, and a `EuiErrorBoundary` to contain errors. + * @param Component A component deferred by `React.lazy` + * @param fallback A fallback component to render while things load; default is `EuiLoadingSpinner` + */ +export const withSuspense =

( + Component: ComponentType

, + fallback: ReactElement | null = +) => + React.forwardRef((props: P, ref: Ref) => { + return ( + + + + + + ); + }); + +export const LazyReplacementCard = React.lazy(() => import('./replacement_card')); diff --git a/packages/kbn-legacy-logging/jest.config.js b/src/plugins/custom_integrations/public/components/replacement_card/index.ts similarity index 58% rename from packages/kbn-legacy-logging/jest.config.js rename to src/plugins/custom_integrations/public/components/replacement_card/index.ts index d00b1c56dae81..631dc1fcb2ba2 100644 --- a/packages/kbn-legacy-logging/jest.config.js +++ b/src/plugins/custom_integrations/public/components/replacement_card/index.ts @@ -6,8 +6,10 @@ * Side Public License, v 1. */ -module.exports = { - preset: '@kbn/test', - rootDir: '../..', - roots: ['/packages/kbn-legacy-logging'], -}; +import { ReplacementCard } from './replacement_card'; + +export { ReplacementCard, Props } from './replacement_card'; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ReplacementCard; diff --git a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx new file mode 100644 index 0000000000000..f66d13fb911b5 --- /dev/null +++ b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +/** @jsx jsx */ + +import { css, jsx } from '@emotion/react'; + +import { + htmlIdGenerator, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiText, + EuiAccordion, + EuiLink, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { CustomIntegration } from '../../../common'; +import { usePlatformService } from '../../services'; + +export interface Props { + replacements: Array>; +} + +// TODO - clintandrewhall: should use doc-links service +const URL_COMPARISON = 'https://ela.st/beats-agent-comparison'; + +const idGenerator = htmlIdGenerator('replacementCard'); +const alsoAvailable = i18n.translate('customIntegrations.components.replacementAccordionLabel', { + defaultMessage: 'Also available in Beats', +}); + +const link = ( + + + +); + +/** + * A pure component, an accordion panel which can display information about replacements for a given EPR module. + */ +export const ReplacementCard = ({ replacements }: Props) => { + const { euiTheme } = useEuiTheme(); + const { getAbsolutePath } = usePlatformService(); + + if (replacements.length === 0) { + return null; + } + + const buttons = replacements.map((replacement) => ( + + + + {replacement.title} + + + + )); + + return ( +

+ + + + + + + + + + + {buttons} + + + + + +
+ ); +}; diff --git a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.stories.tsx b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.stories.tsx new file mode 100644 index 0000000000000..8fa0674c9b467 --- /dev/null +++ b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.stories.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 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 { Meta } from '@storybook/react'; + +import { ReplacementCard as ConnectedComponent } from './replacement_card'; +import { ReplacementCard as PureComponent } from './replacement_card.component'; + +export default { + title: 'Replacement Card', + description: + 'An accordion panel which can display information about Beats alternatives to a given EPR module, (if available)', + decorators: [ + (storyFn, { globals }) => ( +
+ {storyFn()} +
+ ), + ], +} as Meta; + +interface Args { + eprPackageName: string; +} + +const args: Args = { + eprPackageName: 'nginx', +}; + +const argTypes = { + eprPackageName: { + control: { + type: 'radio', + options: ['nginx', 'okta', 'aws', 'apache'], + }, + }, +}; + +export function ReplacementCard({ eprPackageName }: Args) { + return ; +} + +ReplacementCard.args = args; +ReplacementCard.argTypes = argTypes; + +export function Component() { + return ( + + ); +} diff --git a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.tsx b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.tsx new file mode 100644 index 0000000000000..3e829270773a6 --- /dev/null +++ b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 useAsync from 'react-use/lib/useAsync'; +import { useFindService } from '../../services'; + +import { ReplacementCard as Component } from './replacement_card.component'; + +export interface Props { + eprPackageName: string; +} + +/** + * A data-connected component which can query about Beats-based replacement options for a given EPR module. + */ +export const ReplacementCard = ({ eprPackageName }: Props) => { + const { findReplacementIntegrations } = useFindService(); + const integrations = useAsync(async () => { + return await findReplacementIntegrations({ shipper: 'beats', eprPackageName }); + }, [eprPackageName]); + + const { loading, value: replacements } = integrations; + + if (loading || !replacements || replacements.length === 0) { + return null; + } + + return ; +}; diff --git a/src/plugins/custom_integrations/public/index.ts b/src/plugins/custom_integrations/public/index.ts index 9e979dd6692bc..91da75c634a44 100755 --- a/src/plugins/custom_integrations/public/index.ts +++ b/src/plugins/custom_integrations/public/index.ts @@ -13,4 +13,8 @@ import { CustomIntegrationsPlugin } from './plugin'; export function plugin() { return new CustomIntegrationsPlugin(); } + export { CustomIntegrationsSetup, CustomIntegrationsStart } from './types'; + +export { withSuspense, LazyReplacementCard } from './components'; +export { filterCustomIntegrations } from './services/find'; diff --git a/src/plugins/custom_integrations/public/mocks.ts b/src/plugins/custom_integrations/public/mocks.ts index 2e6bc491c2c5c..a8fedbbb712b2 100644 --- a/src/plugins/custom_integrations/public/mocks.ts +++ b/src/plugins/custom_integrations/public/mocks.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -import { CustomIntegrationsSetup } from './types'; +import { pluginServices } from './services'; +import { PluginServiceRegistry } from '../../presentation_util/public'; +import { CustomIntegrationsSetup, CustomIntegrationsStart } from './types'; +import { CustomIntegrationsServices } from './services'; +import { providers } from './services/stub'; function createCustomIntegrationsSetup(): jest.Mocked { const mock: jest.Mocked = { @@ -16,6 +20,17 @@ function createCustomIntegrationsSetup(): jest.Mocked { return mock; } +function createCustomIntegrationsStart(): jest.Mocked { + const registry = new PluginServiceRegistry(providers); + pluginServices.setRegistry(registry.start({})); + const ContextProvider = pluginServices.getContextProvider(); + + return { + ContextProvider: jest.fn(ContextProvider), + }; +} + export const customIntegrationsMock = { createSetup: createCustomIntegrationsSetup, + createStart: createCustomIntegrationsStart, }; diff --git a/src/plugins/custom_integrations/public/plugin.ts b/src/plugins/custom_integrations/public/plugin.ts index 7ea7a829e8072..a3470fefba46c 100755 --- a/src/plugins/custom_integrations/public/plugin.ts +++ b/src/plugins/custom_integrations/public/plugin.ts @@ -7,13 +7,20 @@ */ import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { CustomIntegrationsSetup, CustomIntegrationsStart } from './types'; +import { + CustomIntegrationsSetup, + CustomIntegrationsStart, + CustomIntegrationsStartDependencies, +} from './types'; import { CustomIntegration, ROUTES_APPEND_CUSTOM_INTEGRATIONS, ROUTES_REPLACEMENT_CUSTOM_INTEGRATIONS, } from '../common'; +import { pluginServices } from './services'; +import { pluginServiceRegistry } from './services/kibana'; + export class CustomIntegrationsPlugin implements Plugin { @@ -30,8 +37,14 @@ export class CustomIntegrationsPlugin }; } - public start(core: CoreStart): CustomIntegrationsStart { - return {}; + public start( + coreStart: CoreStart, + startPlugins: CustomIntegrationsStartDependencies + ): CustomIntegrationsStart { + pluginServices.setRegistry(pluginServiceRegistry.start({ coreStart, startPlugins })); + return { + ContextProvider: pluginServices.getContextProvider(), + }; } public stop() {} diff --git a/src/plugins/custom_integrations/public/services/find.test.ts b/src/plugins/custom_integrations/public/services/find.test.ts new file mode 100644 index 0000000000000..df52c22313b68 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/find.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ +/* + * 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 { filterCustomIntegrations } from './find'; +import { CustomIntegration } from '../../common'; + +describe('Custom Integrations Find Service', () => { + const integrations: CustomIntegration[] = [ + { + id: 'foo', + title: 'Foo', + description: 'test integration', + type: 'ui_link', + uiInternalPath: '/path/to/foo', + isBeta: false, + icons: [], + categories: ['aws', 'cloud'], + shipper: 'tests', + }, + { + id: 'bar', + title: 'Bar', + description: 'test integration', + type: 'ui_link', + uiInternalPath: '/path/to/bar', + isBeta: false, + icons: [], + categories: ['aws'], + shipper: 'other', + eprOverlap: 'eprValue', + }, + { + id: 'bar', + title: 'Bar', + description: 'test integration', + type: 'ui_link', + uiInternalPath: '/path/to/bar', + isBeta: false, + icons: [], + categories: ['cloud'], + shipper: 'other', + eprOverlap: 'eprValue', + }, + { + id: 'baz', + title: 'Baz', + description: 'test integration', + type: 'ui_link', + uiInternalPath: '/path/to/baz', + isBeta: false, + icons: [], + categories: ['cloud'], + shipper: 'tests', + eprOverlap: 'eprOtherValue', + }, + ]; + + describe('filterCustomIntegrations', () => { + test('filters on shipper', () => { + let result = filterCustomIntegrations(integrations, { shipper: 'other' }); + expect(result.length).toBe(2); + result = filterCustomIntegrations(integrations, { shipper: 'tests' }); + expect(result.length).toBe(2); + result = filterCustomIntegrations(integrations, { shipper: 'foobar' }); + expect(result.length).toBe(0); + }); + test('filters on eprOverlap', () => { + let result = filterCustomIntegrations(integrations, { eprPackageName: 'eprValue' }); + expect(result.length).toBe(2); + result = filterCustomIntegrations(integrations, { eprPackageName: 'eprOtherValue' }); + expect(result.length).toBe(1); + result = filterCustomIntegrations(integrations, { eprPackageName: 'otherValue' }); + expect(result.length).toBe(0); + }); + test('filters on categories and shipper, eprOverlap', () => { + const result = filterCustomIntegrations(integrations, { + shipper: 'other', + eprPackageName: 'eprValue', + }); + expect(result.length).toBe(2); + }); + }); +}); diff --git a/src/plugins/custom_integrations/public/services/find.ts b/src/plugins/custom_integrations/public/services/find.ts new file mode 100644 index 0000000000000..4e69327c351b4 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/find.ts @@ -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 { CustomIntegration } from '../../common'; + +interface FindParams { + eprPackageName?: string; + shipper?: string; +} + +/** + * A plugin service that finds and returns custom integrations. + */ +export interface CustomIntegrationsFindService { + findReplacementIntegrations(params?: FindParams): Promise; + findAppendedIntegrations(params?: FindParams): Promise; +} + +/** + * Filter a set of integrations by eprPackageName, and/or shipper. + */ +export const filterCustomIntegrations = ( + integrations: CustomIntegration[], + { eprPackageName, shipper }: FindParams = {} +) => { + if (!eprPackageName && !shipper) { + return integrations; + } + + let result = integrations; + + if (eprPackageName) { + result = result.filter((integration) => integration.eprOverlap === eprPackageName); + } + + if (shipper) { + result = result.filter((integration) => integration.shipper === shipper); + } + + return result; +}; diff --git a/src/plugins/custom_integrations/public/services/index.ts b/src/plugins/custom_integrations/public/services/index.ts new file mode 100644 index 0000000000000..8a257ee1a2cd7 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/index.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 { PluginServices } from '../../../presentation_util/public'; + +import { CustomIntegrationsFindService } from './find'; +import { CustomIntegrationsPlatformService } from './platform'; + +/** + * Services used by the custom integrations plugin. + */ +export interface CustomIntegrationsServices { + find: CustomIntegrationsFindService; + platform: CustomIntegrationsPlatformService; +} + +/** + * The `PluginServices` object for the custom integrations plugin. + * @see /src/plugins/presentation_util/public/services/create/index.ts + */ +export const pluginServices = new PluginServices(); + +/** + * A React hook that provides connections to the `CustomIntegrationsFindService`. + */ +export const useFindService = () => (() => pluginServices.getHooks().find.useService())(); + +/** + * A React hook that provides connections to the `CustomIntegrationsPlatformService`. + */ +export const usePlatformService = () => (() => pluginServices.getHooks().platform.useService())(); diff --git a/src/plugins/custom_integrations/public/services/kibana/find.ts b/src/plugins/custom_integrations/public/services/kibana/find.ts new file mode 100644 index 0000000000000..5fc7626baa1e1 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/kibana/find.ts @@ -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 { + CustomIntegration, + ROUTES_APPEND_CUSTOM_INTEGRATIONS, + ROUTES_REPLACEMENT_CUSTOM_INTEGRATIONS, +} from '../../../common'; +import { KibanaPluginServiceFactory } from '../../../../presentation_util/public'; + +import { CustomIntegrationsStartDependencies } from '../../types'; +import { CustomIntegrationsFindService, filterCustomIntegrations } from '../find'; + +/** + * A type definition for a factory to produce the `CustomIntegrationsFindService` for use in Kibana. + * @see /src/plugins/presentation_util/public/services/create/factory.ts + */ +export type CustomIntegrationsFindServiceFactory = KibanaPluginServiceFactory< + CustomIntegrationsFindService, + CustomIntegrationsStartDependencies +>; + +/** + * A factory to produce the `CustomIntegrationsFindService` for use in Kibana. + */ +export const findServiceFactory: CustomIntegrationsFindServiceFactory = ({ coreStart }) => ({ + findAppendedIntegrations: async (params) => { + const integrations: CustomIntegration[] = await coreStart.http.get( + ROUTES_APPEND_CUSTOM_INTEGRATIONS + ); + + return filterCustomIntegrations(integrations, params); + }, + findReplacementIntegrations: async (params) => { + const replacements: CustomIntegration[] = await coreStart.http.get( + ROUTES_REPLACEMENT_CUSTOM_INTEGRATIONS + ); + + return filterCustomIntegrations(replacements, params); + }, +}); diff --git a/src/plugins/custom_integrations/public/services/kibana/index.ts b/src/plugins/custom_integrations/public/services/kibana/index.ts new file mode 100644 index 0000000000000..d3cf27b9bc7c0 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/kibana/index.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 { + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, + KibanaPluginServiceParams, +} from '../../../../presentation_util/public'; + +import { CustomIntegrationsServices } from '..'; +import { CustomIntegrationsStartDependencies } from '../../types'; + +import { findServiceFactory } from './find'; +import { platformServiceFactory } from './platform'; + +export { findServiceFactory } from './find'; +export { platformServiceFactory } from './platform'; + +/** + * A set of `PluginServiceProvider`s for use in Kibana. + * @see /src/plugins/presentation_util/public/services/create/provider.tsx + */ +export const pluginServiceProviders: PluginServiceProviders< + CustomIntegrationsServices, + KibanaPluginServiceParams +> = { + find: new PluginServiceProvider(findServiceFactory), + platform: new PluginServiceProvider(platformServiceFactory), +}; + +/** + * A `PluginServiceRegistry` for use in Kibana. + * @see /src/plugins/presentation_util/public/services/create/registry.tsx + */ +export const pluginServiceRegistry = new PluginServiceRegistry< + CustomIntegrationsServices, + KibanaPluginServiceParams +>(pluginServiceProviders); diff --git a/src/plugins/custom_integrations/public/services/kibana/platform.ts b/src/plugins/custom_integrations/public/services/kibana/platform.ts new file mode 100644 index 0000000000000..e6fe89b68c975 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/kibana/platform.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. + */ + +import { KibanaPluginServiceFactory } from '../../../../presentation_util/public'; + +import type { CustomIntegrationsPlatformService } from '../platform'; +import type { CustomIntegrationsStartDependencies } from '../../types'; + +/** + * A type definition for a factory to produce the `CustomIntegrationsPlatformService` for use in Kibana. + * @see /src/plugins/presentation_util/public/services/create/factory.ts + */ +export type CustomIntegrationsPlatformServiceFactory = KibanaPluginServiceFactory< + CustomIntegrationsPlatformService, + CustomIntegrationsStartDependencies +>; + +/** + * A factory to produce the `CustomIntegrationsPlatformService` for use in Kibana. + */ +export const platformServiceFactory: CustomIntegrationsPlatformServiceFactory = ({ + coreStart, +}) => ({ + getBasePath: coreStart.http.basePath.get, + getAbsolutePath: (path: string): string => coreStart.http.basePath.prepend(`${path}`), +}); diff --git a/packages/kbn-legacy-logging/src/utils/index.ts b/src/plugins/custom_integrations/public/services/platform.ts similarity index 73% rename from packages/kbn-legacy-logging/src/utils/index.ts rename to src/plugins/custom_integrations/public/services/platform.ts index 3036671121fe0..0eb9c7d5c3c10 100644 --- a/packages/kbn-legacy-logging/src/utils/index.ts +++ b/src/plugins/custom_integrations/public/services/platform.ts @@ -6,5 +6,7 @@ * Side Public License, v 1. */ -export { applyFiltersToKeys } from './apply_filters_to_keys'; -export { getResponsePayloadBytes } from './get_payload_size'; +export interface CustomIntegrationsPlatformService { + getBasePath: () => string; + getAbsolutePath: (path: string) => string; +} diff --git a/src/plugins/custom_integrations/public/services/storybook/index.ts b/src/plugins/custom_integrations/public/services/storybook/index.ts new file mode 100644 index 0000000000000..4dfed1b37e294 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/storybook/index.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, +} from '../../../../presentation_util/public'; + +import { CustomIntegrationsServices } from '..'; +import { findServiceFactory } from '../stub/find'; +import { platformServiceFactory } from '../stub/platform'; + +export { findServiceFactory } from '../stub/find'; +export { platformServiceFactory } from '../stub/platform'; + +/** + * A set of `PluginServiceProvider`s for use in Storybook. + * @see /src/plugins/presentation_util/public/services/create/provider.tsx + */ +export const providers: PluginServiceProviders = { + find: new PluginServiceProvider(findServiceFactory), + platform: new PluginServiceProvider(platformServiceFactory), +}; + +/** + * A `PluginServiceRegistry` for use in Storybook. + * @see /src/plugins/presentation_util/public/services/create/registry.tsx + */ +export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/custom_integrations/public/services/stub/find.ts b/src/plugins/custom_integrations/public/services/stub/find.ts new file mode 100644 index 0000000000000..08def4e63471d --- /dev/null +++ b/src/plugins/custom_integrations/public/services/stub/find.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. + */ + +import { PluginServiceFactory } from '../../../../presentation_util/public'; + +import { CustomIntegrationsFindService, filterCustomIntegrations } from '../find'; + +/** + * A type definition for a factory to produce the `CustomIntegrationsFindService` with stubbed output. + * @see /src/plugins/presentation_util/public/services/create/factory.ts + */ +export type CustomIntegrationsFindServiceFactory = + PluginServiceFactory; + +/** + * A factory to produce the `CustomIntegrationsFindService` with stubbed output. + */ +export const findServiceFactory: CustomIntegrationsFindServiceFactory = () => ({ + findAppendedIntegrations: async (params) => { + const { integrations } = await import('./fixtures/integrations'); + return filterCustomIntegrations(integrations, params); + }, + findReplacementIntegrations: async (params) => { + const { integrations } = await import('./fixtures/integrations'); + return filterCustomIntegrations(integrations, params); + }, +}); diff --git a/src/plugins/custom_integrations/public/services/stub/fixtures/integrations.ts b/src/plugins/custom_integrations/public/services/stub/fixtures/integrations.ts new file mode 100644 index 0000000000000..7553deada9e26 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/stub/fixtures/integrations.ts @@ -0,0 +1,1884 @@ +/* + * 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 { CustomIntegration } from '../../../../common'; + +export const integrations: CustomIntegration[] = [ + { + type: 'ui_link', + id: 'System logs', + title: 'System logs', + categories: ['os_system', 'security'], + uiInternalPath: '/app/home#/tutorial/systemLogs', + description: 'Collect system logs of common Unix/Linux based distributions.', + icons: [ + { + type: 'eui', + src: 'logoLogging', + }, + ], + shipper: 'beats', + eprOverlap: 'system', + isBeta: false, + }, + { + type: 'ui_link', + id: 'System metrics', + title: 'System metrics', + categories: ['os_system', 'security'], + uiInternalPath: '/app/home#/tutorial/systemMetrics', + description: 'Collect CPU, memory, network, and disk statistics from the host.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/system.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'system', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Apache logs', + title: 'Apache logs', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/apacheLogs', + description: 'Collect and parse access and error logs created by the Apache HTTP server.', + icons: [ + { + type: 'eui', + src: 'logoApache', + }, + ], + shipper: 'beats', + eprOverlap: 'apache', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Apache metrics', + title: 'Apache metrics', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/apacheMetrics', + description: 'Fetch internal metrics from the Apache 2 HTTP server.', + icons: [ + { + type: 'eui', + src: 'logoApache', + }, + ], + shipper: 'beats', + eprOverlap: 'apache', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Elasticsearch logs', + title: 'Elasticsearch logs', + categories: ['containers', 'os_system'], + uiInternalPath: '/app/home#/tutorial/elasticsearchLogs', + description: 'Collect and parse logs created by Elasticsearch.', + icons: [ + { + type: 'eui', + src: 'logoElasticsearch', + }, + ], + shipper: 'beats', + eprOverlap: 'elasticsearch', + isBeta: false, + }, + { + type: 'ui_link', + id: 'IIS logs', + title: 'IIS logs', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/iisLogs', + description: 'Collect and parse access and error logs created by the IIS HTTP server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/iis.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'iis', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Kafka logs', + title: 'Kafka logs', + categories: ['message_queue'], + uiInternalPath: '/app/home#/tutorial/kafkaLogs', + description: 'Collect and parse logs created by Kafka.', + icons: [ + { + type: 'eui', + src: 'logoKafka', + }, + ], + shipper: 'beats', + eprOverlap: 'kafka', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Logstash logs', + title: 'Logstash logs', + categories: ['custom'], + uiInternalPath: '/app/home#/tutorial/logstashLogs', + description: 'Collect Logstash main and slow logs.', + icons: [ + { + type: 'eui', + src: 'logoLogstash', + }, + ], + shipper: 'beats', + eprOverlap: 'logstash', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Nginx logs', + title: 'Nginx logs', + categories: ['web', 'security'], + uiInternalPath: '/app/home#/tutorial/nginxLogs', + description: 'Collect and parse access and error logs created by the Nginx HTTP server.', + icons: [ + { + type: 'eui', + src: 'logoNginx', + }, + ], + shipper: 'beats', + eprOverlap: 'nginx', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Nginx metrics', + title: 'Nginx metrics', + categories: ['web', 'security'], + uiInternalPath: '/app/home#/tutorial/nginxMetrics', + description: 'Fetch internal metrics from the Nginx HTTP server.', + icons: [ + { + type: 'eui', + src: 'logoNginx', + }, + ], + shipper: 'beats', + eprOverlap: 'nginx', + isBeta: false, + }, + { + type: 'ui_link', + id: 'MySQL logs', + title: 'MySQL logs', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/mysqlLogs', + description: 'Collect and parse error and slow logs created by MySQL.', + icons: [ + { + type: 'eui', + src: 'logoMySQL', + }, + ], + shipper: 'beats', + eprOverlap: 'mysql', + isBeta: false, + }, + { + type: 'ui_link', + id: 'MySQL metrics', + title: 'MySQL metrics', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/mysqlMetrics', + description: 'Fetch internal metrics from MySQL.', + icons: [ + { + type: 'eui', + src: 'logoMySQL', + }, + ], + shipper: 'beats', + eprOverlap: 'mysql', + isBeta: false, + }, + { + type: 'ui_link', + id: 'MongoDB metrics', + title: 'MongoDB metrics', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/mongodbMetrics', + description: 'Fetch internal metrics from MongoDB.', + icons: [ + { + type: 'eui', + src: 'logoMongodb', + }, + ], + shipper: 'beats', + eprOverlap: 'mongodb', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Osquery logs', + title: 'Osquery logs', + categories: ['security', 'os_system'], + uiInternalPath: '/app/home#/tutorial/osqueryLogs', + description: 'Collect osquery logs in JSON format.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/osquery.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'osquery', + isBeta: false, + }, + { + type: 'ui_link', + id: 'PHP-FPM metrics', + title: 'PHP-FPM metrics', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/phpfpmMetrics', + description: 'Fetch internal metrics from PHP-FPM.', + icons: [ + { + type: 'eui', + src: 'logoPhp', + }, + ], + shipper: 'beats', + eprOverlap: 'php_fpm', + isBeta: false, + }, + { + type: 'ui_link', + id: 'PostgreSQL metrics', + title: 'PostgreSQL metrics', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/postgresqlMetrics', + description: 'Fetch internal metrics from PostgreSQL.', + icons: [ + { + type: 'eui', + src: 'logoPostgres', + }, + ], + shipper: 'beats', + eprOverlap: 'postgresql', + isBeta: false, + }, + { + type: 'ui_link', + id: 'PostgreSQL logs', + title: 'PostgreSQL logs', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/postgresqlLogs', + description: 'Collect and parse error and slow logs created by PostgreSQL.', + icons: [ + { + type: 'eui', + src: 'logoPostgres', + }, + ], + shipper: 'beats', + eprOverlap: 'postgresql', + isBeta: false, + }, + { + type: 'ui_link', + id: 'RabbitMQ metrics', + title: 'RabbitMQ metrics', + categories: ['message_queue'], + uiInternalPath: '/app/home#/tutorial/rabbitmqMetrics', + description: 'Fetch internal metrics from the RabbitMQ server.', + icons: [ + { + type: 'eui', + src: 'logoRabbitmq', + }, + ], + shipper: 'beats', + eprOverlap: 'rabbitmq', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Redis logs', + title: 'Redis logs', + categories: ['datastore', 'message_queue'], + uiInternalPath: '/app/home#/tutorial/redisLogs', + description: 'Collect and parse error and slow logs created by Redis.', + icons: [ + { + type: 'eui', + src: 'logoRedis', + }, + ], + shipper: 'beats', + eprOverlap: 'redis', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Redis metrics', + title: 'Redis metrics', + categories: ['datastore', 'message_queue'], + uiInternalPath: '/app/home#/tutorial/redisMetrics', + description: 'Fetch internal metrics from Redis.', + icons: [ + { + type: 'eui', + src: 'logoRedis', + }, + ], + shipper: 'beats', + eprOverlap: 'redis', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Suricata logs', + title: 'Suricata logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/suricataLogs', + description: 'Collect Suricata IDS/IPS/NSM logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/suricata.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'suricata', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Docker metrics', + title: 'Docker metrics', + categories: ['containers', 'os_system'], + uiInternalPath: '/app/home#/tutorial/dockerMetrics', + description: 'Fetch metrics about your Docker containers.', + icons: [ + { + type: 'eui', + src: 'logoDocker', + }, + ], + shipper: 'beats', + eprOverlap: 'docker', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Kubernetes metrics', + title: 'Kubernetes metrics', + categories: ['containers', 'kubernetes'], + uiInternalPath: '/app/home#/tutorial/kubernetesMetrics', + description: 'Fetch metrics from your Kubernetes installation.', + icons: [ + { + type: 'eui', + src: 'logoKubernetes', + }, + ], + shipper: 'beats', + eprOverlap: 'kubernetes', + isBeta: false, + }, + { + type: 'ui_link', + id: 'uWSGI metrics', + title: 'uWSGI metrics', + categories: ['web', 'security'], + uiInternalPath: '/app/home#/tutorial/uwsgiMetrics', + description: 'Fetch internal metrics from the uWSGI server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/uwsgi.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'uwsgi', + isBeta: false, + }, + { + type: 'ui_link', + id: 'NetFlow / IPFIX Collector', + title: 'NetFlow / IPFIX Collector', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/netflowLogs', + description: 'Collect NetFlow and IPFIX flow records.', + icons: [ + { + type: 'eui', + src: 'logoBeats', + }, + ], + shipper: 'beats', + eprOverlap: 'netflow', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Traefik logs', + title: 'Traefik logs', + categories: ['web', 'security'], + uiInternalPath: '/app/home#/tutorial/traefikLogs', + description: 'Collect Traefik access logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/traefik.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'traefik', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Ceph metrics', + title: 'Ceph metrics', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/cephMetrics', + description: 'Fetch internal metrics from the Ceph server.', + icons: [ + { + type: 'eui', + src: 'logoCeph', + }, + ], + shipper: 'beats', + eprOverlap: 'ceph', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Aerospike metrics', + title: 'Aerospike metrics', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/aerospikeMetrics', + description: 'Fetch internal metrics from the Aerospike server.', + icons: [ + { + type: 'eui', + src: 'logoAerospike', + }, + ], + shipper: 'beats', + eprOverlap: 'aerospike', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Couchbase metrics', + title: 'Couchbase metrics', + categories: ['security', 'network', 'web'], + uiInternalPath: '/app/home#/tutorial/couchbaseMetrics', + description: 'Fetch internal metrics from Couchbase.', + icons: [ + { + type: 'eui', + src: 'logoCouchbase', + }, + ], + shipper: 'beats', + eprOverlap: 'couchbase', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Dropwizard metrics', + title: 'Dropwizard metrics', + categories: ['elastic_stack', 'datastore'], + uiInternalPath: '/app/home#/tutorial/dropwizardMetrics', + description: 'Fetch internal metrics from Dropwizard Java application.', + icons: [ + { + type: 'eui', + src: 'logoDropwizard', + }, + ], + shipper: 'beats', + eprOverlap: 'dropwizard', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Elasticsearch metrics', + title: 'Elasticsearch metrics', + categories: ['elastic_stack', 'datastore'], + uiInternalPath: '/app/home#/tutorial/elasticsearchMetrics', + description: 'Fetch internal metrics from Elasticsearch.', + icons: [ + { + type: 'eui', + src: 'logoElasticsearch', + }, + ], + shipper: 'beats', + eprOverlap: 'elasticsearch', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Etcd metrics', + title: 'Etcd metrics', + categories: ['elastic_stack', 'datastore'], + uiInternalPath: '/app/home#/tutorial/etcdMetrics', + description: 'Fetch internal metrics from the Etcd server.', + icons: [ + { + type: 'eui', + src: 'logoEtcd', + }, + ], + shipper: 'beats', + eprOverlap: 'etcd', + isBeta: false, + }, + { + type: 'ui_link', + id: 'HAProxy metrics', + title: 'HAProxy metrics', + categories: ['network', 'web'], + uiInternalPath: '/app/home#/tutorial/haproxyMetrics', + description: 'Fetch internal metrics from the HAProxy server.', + icons: [ + { + type: 'eui', + src: 'logoHAproxy', + }, + ], + shipper: 'beats', + eprOverlap: 'haproxy', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Kafka metrics', + title: 'Kafka metrics', + categories: ['message_queue'], + uiInternalPath: '/app/home#/tutorial/kafkaMetrics', + description: 'Fetch internal metrics from the Kafka server.', + icons: [ + { + type: 'eui', + src: 'logoKafka', + }, + ], + shipper: 'beats', + eprOverlap: 'kafka', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Kibana metrics', + title: 'Kibana metrics', + categories: ['message_queue'], + uiInternalPath: '/app/home#/tutorial/kibanaMetrics', + description: 'Fetch internal metrics from Kibana.', + icons: [ + { + type: 'eui', + src: 'logoKibana', + }, + ], + shipper: 'beats', + eprOverlap: 'kibana', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Memcached metrics', + title: 'Memcached metrics', + categories: ['custom'], + uiInternalPath: '/app/home#/tutorial/memcachedMetrics', + description: 'Fetch internal metrics from the Memcached server.', + icons: [ + { + type: 'eui', + src: 'logoMemcached', + }, + ], + shipper: 'beats', + eprOverlap: 'memcached', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Munin metrics', + title: 'Munin metrics', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/muninMetrics', + description: 'Fetch internal metrics from the Munin server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/munin.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'munin', + isBeta: false, + }, + { + type: 'ui_link', + id: 'vSphere metrics', + title: 'vSphere metrics', + categories: ['web', 'security'], + uiInternalPath: '/app/home#/tutorial/vsphereMetrics', + description: 'Fetch internal metrics from vSphere.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/vsphere.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'vsphere', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Windows metrics', + title: 'Windows metrics', + categories: ['os_system', 'security'], + uiInternalPath: '/app/home#/tutorial/windowsMetrics', + description: 'Fetch internal metrics from Windows.', + icons: [ + { + type: 'eui', + src: 'logoWindows', + }, + ], + shipper: 'beats', + eprOverlap: 'windows', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Windows Event Log', + title: 'Windows Event Log', + categories: ['os_system', 'security'], + uiInternalPath: '/app/home#/tutorial/windowsEventLogs', + description: 'Fetch logs from the Windows Event Log.', + icons: [ + { + type: 'eui', + src: 'logoWindows', + }, + ], + shipper: 'beats', + eprOverlap: 'windows', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Golang metrics', + title: 'Golang metrics', + categories: ['google_cloud', 'cloud', 'network', 'security'], + uiInternalPath: '/app/home#/tutorial/golangMetrics', + description: 'Fetch internal metrics from a Golang app.', + icons: [ + { + type: 'eui', + src: 'logoGolang', + }, + ], + shipper: 'beats', + eprOverlap: 'golang', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Logstash metrics', + title: 'Logstash metrics', + categories: ['custom'], + uiInternalPath: '/app/home#/tutorial/logstashMetrics', + description: 'Fetch internal metrics from a Logstash server.', + icons: [ + { + type: 'eui', + src: 'logoLogstash', + }, + ], + shipper: 'beats', + eprOverlap: 'logstash', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Prometheus metrics', + title: 'Prometheus metrics', + categories: ['monitoring', 'datastore'], + uiInternalPath: '/app/home#/tutorial/prometheusMetrics', + description: 'Fetch metrics from a Prometheus exporter.', + icons: [ + { + type: 'eui', + src: 'logoPrometheus', + }, + ], + shipper: 'beats', + eprOverlap: 'prometheus', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Zookeeper metrics', + title: 'Zookeeper metrics', + categories: ['datastore', 'config_management'], + uiInternalPath: '/app/home#/tutorial/zookeeperMetrics', + description: 'Fetch internal metrics from a Zookeeper server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/zookeeper.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'zookeeper', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Uptime Monitors', + title: 'Uptime Monitors', + categories: ['web', 'security'], + uiInternalPath: '/app/home#/tutorial/uptimeMonitors', + description: 'Monitor services for their availability', + icons: [ + { + type: 'eui', + src: 'uptimeApp', + }, + ], + shipper: 'beats', + eprOverlap: 'uptime', + isBeta: false, + }, + { + type: 'ui_link', + id: 'AWS Cloudwatch logs', + title: 'AWS Cloudwatch logs', + categories: ['security', 'network', 'web'], + uiInternalPath: '/app/home#/tutorial/cloudwatchLogs', + description: 'Collect Cloudwatch logs with Functionbeat.', + icons: [ + { + type: 'eui', + src: 'logoAWS', + }, + ], + shipper: 'beats', + eprOverlap: 'aws', + isBeta: false, + }, + { + type: 'ui_link', + id: 'AWS metrics', + title: 'AWS metrics', + categories: ['aws', 'cloud', 'datastore', 'security', 'network'], + uiInternalPath: '/app/home#/tutorial/awsMetrics', + description: 'Fetch monitoring metrics for EC2 instances from the AWS APIs and Cloudwatch.', + icons: [ + { + type: 'eui', + src: 'logoAWS', + }, + ], + shipper: 'beats', + eprOverlap: 'aws', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Microsoft SQL Server Metrics', + title: 'Microsoft SQL Server Metrics', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/mssqlMetrics', + description: 'Fetch monitoring metrics from a Microsoft SQL Server instance', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/mssql.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'mssql', + isBeta: false, + }, + { + type: 'ui_link', + id: 'NATS metrics', + title: 'NATS metrics', + categories: ['message_queue'], + uiInternalPath: '/app/home#/tutorial/natsMetrics', + description: 'Fetch monitoring metrics from the Nats server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/nats.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'nats', + isBeta: false, + }, + { + type: 'ui_link', + id: 'NATS logs', + title: 'NATS logs', + categories: ['message_queue'], + uiInternalPath: '/app/home#/tutorial/natsLogs', + description: 'Collect and parse logs created by Nats.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/nats.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'nats', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Zeek logs', + title: 'Zeek logs', + categories: ['network', 'monitoring', 'security'], + uiInternalPath: '/app/home#/tutorial/zeekLogs', + description: 'Collect Zeek network security monitoring logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/zeek.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'zeek', + isBeta: false, + }, + { + type: 'ui_link', + id: 'CoreDNS metrics', + title: 'CoreDNS metrics', + categories: ['security', 'network', 'web'], + uiInternalPath: '/app/home#/tutorial/corednsMetrics', + description: 'Fetch monitoring metrics from the CoreDNS server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/coredns.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'coredns', + isBeta: false, + }, + { + type: 'ui_link', + id: 'CoreDNS logs', + title: 'CoreDNS logs', + categories: ['security', 'network', 'web'], + uiInternalPath: '/app/home#/tutorial/corednsLogs', + description: 'Collect CoreDNS logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/coredns.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'coredns', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Auditbeat', + title: 'Auditbeat', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/auditbeat', + description: 'Collect audit data from your hosts.', + icons: [ + { + type: 'eui', + src: 'securityAnalyticsApp', + }, + ], + shipper: 'beats', + eprOverlap: 'auditbeat', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Iptables logs', + title: 'Iptables logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/iptablesLogs', + description: 'Collect iptables and ip6tables logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/linux.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'iptables', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Cisco logs', + title: 'Cisco logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/ciscoLogs', + description: 'Collect Cisco network device logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/cisco.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'cisco', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Envoy Proxy logs', + title: 'Envoy Proxy logs', + categories: ['elastic_stack', 'datastore'], + uiInternalPath: '/app/home#/tutorial/envoyproxyLogs', + description: 'Collect Envoy Proxy logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/envoyproxy.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'envoyproxy', + isBeta: false, + }, + { + type: 'ui_link', + id: 'CouchDB metrics', + title: 'CouchDB metrics', + categories: ['security', 'network', 'web'], + uiInternalPath: '/app/home#/tutorial/couchdbMetrics', + description: 'Fetch monitoring metrics from the CouchdB server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/couchdb.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'couchdb', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Consul metrics', + title: 'Consul metrics', + categories: ['security', 'network', 'web'], + uiInternalPath: '/app/home#/tutorial/consulMetrics', + description: 'Fetch monitoring metrics from the Consul server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/consul.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'consul', + isBeta: false, + }, + { + type: 'ui_link', + id: 'CockroachDB metrics', + title: 'CockroachDB metrics', + categories: ['security', 'network', 'web'], + uiInternalPath: '/app/home#/tutorial/cockroachdbMetrics', + description: 'Fetch monitoring metrics from the CockroachDB server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/cockroachdb.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'cockroachdb', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Traefik metrics', + title: 'Traefik metrics', + categories: ['web', 'security'], + uiInternalPath: '/app/home#/tutorial/traefikMetrics', + description: 'Fetch monitoring metrics from Traefik.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/traefik.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'traefik', + isBeta: false, + }, + { + type: 'ui_link', + id: 'AWS S3 based logs', + title: 'AWS S3 based logs', + categories: ['aws', 'cloud', 'datastore', 'security', 'network'], + uiInternalPath: '/app/home#/tutorial/awsLogs', + description: 'Collect AWS logs from S3 bucket with Filebeat.', + icons: [ + { + type: 'eui', + src: 'logoAWS', + }, + ], + shipper: 'beats', + eprOverlap: 'aws', + isBeta: false, + }, + { + type: 'ui_link', + id: 'ActiveMQ logs', + title: 'ActiveMQ logs', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/activemqLogs', + description: 'Collect ActiveMQ logs with Filebeat.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/activemq.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'activemq', + isBeta: false, + }, + { + type: 'ui_link', + id: 'ActiveMQ metrics', + title: 'ActiveMQ metrics', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/activemqMetrics', + description: 'Fetch monitoring metrics from ActiveMQ instances.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/activemq.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'activemq', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Azure metrics', + title: 'Azure metrics', + categories: ['azure', 'cloud', 'network', 'security'], + uiInternalPath: '/app/home#/tutorial/azureMetrics', + description: 'Fetch Azure Monitor metrics.', + icons: [ + { + type: 'eui', + src: 'logoAzure', + }, + ], + shipper: 'beats', + eprOverlap: 'azure', + isBeta: false, + }, + { + type: 'ui_link', + id: 'IBM MQ logs', + title: 'IBM MQ logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/ibmmqLogs', + description: 'Collect IBM MQ logs with Filebeat.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/ibmmq.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'ibmmq', + isBeta: false, + }, + { + type: 'ui_link', + id: 'IBM MQ metrics', + title: 'IBM MQ metrics', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/ibmmqMetrics', + description: 'Fetch monitoring metrics from IBM MQ instances.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/ibmmq.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'ibmmq', + isBeta: false, + }, + { + type: 'ui_link', + id: 'STAN metrics', + title: 'STAN metrics', + categories: ['message_queue', 'kubernetes'], + uiInternalPath: '/app/home#/tutorial/stanMetrics', + description: 'Fetch monitoring metrics from the STAN server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/stan.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'stan', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Envoy Proxy metrics', + title: 'Envoy Proxy metrics', + categories: ['elastic_stack', 'datastore'], + uiInternalPath: '/app/home#/tutorial/envoyproxyMetrics', + description: 'Fetch monitoring metrics from Envoy Proxy.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/envoyproxy.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'envoyproxy', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Statsd metrics', + title: 'Statsd metrics', + categories: ['message_queue', 'kubernetes'], + uiInternalPath: '/app/home#/tutorial/statsdMetrics', + description: 'Fetch monitoring metrics from statsd.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/statsd.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'statsd', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Redis Enterprise metrics', + title: 'Redis Enterprise metrics', + categories: ['datastore', 'message_queue'], + uiInternalPath: '/app/home#/tutorial/redisenterpriseMetrics', + description: 'Fetch monitoring metrics from Redis Enterprise Server.', + icons: [ + { + type: 'eui', + src: 'logoRedis', + }, + ], + shipper: 'beats', + eprOverlap: 'redisenterprise', + isBeta: false, + }, + { + type: 'ui_link', + id: 'OpenMetrics metrics', + title: 'OpenMetrics metrics', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/openmetricsMetrics', + description: 'Fetch metrics from an endpoint that serves metrics in OpenMetrics format.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/openmetrics.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'openmetrics', + isBeta: false, + }, + { + type: 'ui_link', + id: 'oracle metrics', + title: 'oracle metrics', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/oracleMetrics', + description: 'Fetch internal metrics from a Oracle server.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/oracle.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'oracle', + isBeta: false, + }, + { + type: 'ui_link', + id: 'IIS Metrics', + title: 'IIS Metrics', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/iisMetrics', + description: 'Collect IIS server related metrics.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/iis.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'iis', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Azure logs', + title: 'Azure logs', + categories: ['azure', 'cloud', 'network', 'security'], + uiInternalPath: '/app/home#/tutorial/azureLogs', + description: 'Collects Azure activity and audit related logs.', + icons: [ + { + type: 'eui', + src: 'logoAzure', + }, + ], + shipper: 'beats', + eprOverlap: 'azure', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Google Cloud metrics', + title: 'Google Cloud metrics', + categories: ['google_cloud', 'cloud', 'network', 'security'], + uiInternalPath: '/app/home#/tutorial/gcpMetrics', + description: + 'Fetch monitoring metrics from Google Cloud Platform using Stackdriver Monitoring API.', + icons: [ + { + type: 'eui', + src: 'logoGCP', + }, + ], + shipper: 'beats', + eprOverlap: 'gcp', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Auditd logs', + title: 'Auditd logs', + categories: ['os_system'], + uiInternalPath: '/app/home#/tutorial/auditdLogs', + description: 'Collect logs from the Linux auditd daemon.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/linux.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'auditd', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Barracuda logs', + title: 'Barracuda logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/barracudaLogs', + description: 'Collect Barracuda Web Application Firewall logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/barracuda.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'barracuda', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Bluecoat logs', + title: 'Bluecoat logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/bluecoatLogs', + description: 'Collect Blue Coat Director logs over syslog or from a file.', + icons: [ + { + type: 'eui', + src: 'logoLogging', + }, + ], + shipper: 'beats', + eprOverlap: 'bluecoat', + isBeta: false, + }, + { + type: 'ui_link', + id: 'CEF logs', + title: 'CEF logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/cefLogs', + description: 'Collect Common Event Format (CEF) log data over syslog.', + icons: [ + { + type: 'eui', + src: 'logoLogging', + }, + ], + shipper: 'beats', + eprOverlap: 'cef', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Check Point logs', + title: 'Check Point logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/checkpointLogs', + description: 'Collect Check Point firewall logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/checkpoint.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'checkpoint', + isBeta: false, + }, + { + type: 'ui_link', + id: 'CrowdStrike logs', + title: 'CrowdStrike logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/crowdstrikeLogs', + description: 'Collect CrowdStrike Falcon logs using the Falcon SIEM Connector.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/crowdstrike.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'crowdstrike', + isBeta: false, + }, + { + type: 'ui_link', + id: 'CylancePROTECT logs', + title: 'CylancePROTECT logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/cylanceLogs', + description: 'Collect CylancePROTECT logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/cylance.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'cylance', + isBeta: false, + }, + { + type: 'ui_link', + id: 'F5 logs', + title: 'F5 logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/f5Logs', + description: 'Collect F5 Big-IP Access Policy Manager logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/f5.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'f5', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Fortinet logs', + title: 'Fortinet logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/fortinetLogs', + description: 'Collect Fortinet FortiOS logs over syslog.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/fortinet.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'fortinet', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Google Cloud logs', + title: 'Google Cloud logs', + categories: ['google_cloud', 'cloud', 'network', 'security'], + uiInternalPath: '/app/home#/tutorial/gcpLogs', + description: 'Collect Google Cloud audit, firewall, and VPC flow logs.', + icons: [ + { + type: 'eui', + src: 'logoGoogleG', + }, + ], + shipper: 'beats', + eprOverlap: 'gcp', + isBeta: false, + }, + { + type: 'ui_link', + id: 'GSuite logs', + title: 'GSuite logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/gsuiteLogs', + description: 'Collect GSuite activity reports.', + icons: [ + { + type: 'eui', + src: 'logoGoogleG', + }, + ], + shipper: 'beats', + eprOverlap: 'gsuite', + isBeta: false, + }, + { + type: 'ui_link', + id: 'HAProxy logs', + title: 'HAProxy logs', + categories: ['network', 'web'], + uiInternalPath: '/app/home#/tutorial/haproxyLogs', + description: 'Collect HAProxy logs.', + icons: [ + { + type: 'eui', + src: 'logoHAproxy', + }, + ], + shipper: 'beats', + eprOverlap: 'haproxy', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Icinga logs', + title: 'Icinga logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/icingaLogs', + description: 'Collect Icinga main, debug, and startup logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/icinga.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'icinga', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Imperva logs', + title: 'Imperva logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/impervaLogs', + description: 'Collect Imperva SecureSphere logs over syslog or from a file.', + icons: [ + { + type: 'eui', + src: 'logoLogging', + }, + ], + shipper: 'beats', + eprOverlap: 'imperva', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Infoblox logs', + title: 'Infoblox logs', + categories: ['network'], + uiInternalPath: '/app/home#/tutorial/infobloxLogs', + description: 'Collect Infoblox NIOS logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/infoblox.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'infoblox', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Juniper Logs', + title: 'Juniper Logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/juniperLogs', + description: 'Collect Juniper JUNOS logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/juniper.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'juniper', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Kibana Logs', + title: 'Kibana Logs', + categories: ['message_queue'], + uiInternalPath: '/app/home#/tutorial/kibanaLogs', + description: 'Collect Kibana logs.', + icons: [ + { + type: 'eui', + src: 'logoKibana', + }, + ], + shipper: 'beats', + eprOverlap: 'kibana', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Microsoft Defender ATP logs', + title: 'Microsoft Defender ATP logs', + categories: ['network', 'security', 'azure'], + uiInternalPath: '/app/home#/tutorial/microsoftLogs', + description: 'Collect Microsoft Defender ATP alerts.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/microsoft.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'microsoft', + isBeta: false, + }, + { + type: 'ui_link', + id: 'MISP threat intel logs', + title: 'MISP threat intel logs', + categories: ['network', 'security', 'azure'], + uiInternalPath: '/app/home#/tutorial/mispLogs', + description: 'Collect MISP threat intelligence data with Filebeat.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/misp.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'misp', + isBeta: false, + }, + { + type: 'ui_link', + id: 'MongoDB logs', + title: 'MongoDB logs', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/mongodbLogs', + description: 'Collect MongoDB logs.', + icons: [ + { + type: 'eui', + src: 'logoMongodb', + }, + ], + shipper: 'beats', + eprOverlap: 'mongodb', + isBeta: false, + }, + { + type: 'ui_link', + id: 'MSSQL logs', + title: 'MSSQL logs', + categories: ['datastore'], + uiInternalPath: '/app/home#/tutorial/mssqlLogs', + description: 'Collect MSSQL logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/microsoft.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'mssql', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Arbor Peakflow logs', + title: 'Arbor Peakflow logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/netscoutLogs', + description: 'Collect Netscout Arbor Peakflow SP logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/netscout.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'netscout', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Office 365 logs', + title: 'Office 365 logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/o365Logs', + description: 'Collect Office 365 activity logs via the Office 365 API.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/o365.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'o365', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Okta logs', + title: 'Okta logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/oktaLogs', + description: 'Collect the Okta system log via the Okta API.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/okta.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'okta', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Palo Alto Networks PAN-OS logs', + title: 'Palo Alto Networks PAN-OS logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/panwLogs', + description: + 'Collect Palo Alto Networks PAN-OS threat and traffic logs over syslog or from a log file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/paloalto.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'panw', + isBeta: false, + }, + { + type: 'ui_link', + id: 'RabbitMQ logs', + title: 'RabbitMQ logs', + categories: ['message_queue'], + uiInternalPath: '/app/home#/tutorial/rabbitmqLogs', + description: 'Collect RabbitMQ logs.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/rabbitmq.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'rabbitmq', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Radware DefensePro logs', + title: 'Radware DefensePro logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/radwareLogs', + description: 'Collect Radware DefensePro logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/radware.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'radware', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Google Santa logs', + title: 'Google Santa logs', + categories: ['security', 'os_system'], + uiInternalPath: '/app/home#/tutorial/santaLogs', + description: 'Collect Google Santa logs about process executions on MacOS.', + icons: [ + { + type: 'eui', + src: 'logoLogging', + }, + ], + shipper: 'beats', + eprOverlap: 'santa', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Sonicwall FW logs', + title: 'Sonicwall FW logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/sonicwallLogs', + description: 'Collect Sonicwall-FW logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/sonicwall.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'sonicwall', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Sophos logs', + title: 'Sophos logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/sophosLogs', + description: 'Collect Sophos XG SFOS logs over syslog.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/sophos.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'sophos', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Squid logs', + title: 'Squid logs', + categories: ['security'], + uiInternalPath: '/app/home#/tutorial/squidLogs', + description: 'Collect Squid logs over syslog or from a file.', + icons: [ + { + type: 'eui', + src: 'logoLogging', + }, + ], + shipper: 'beats', + eprOverlap: 'squid', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Tomcat logs', + title: 'Tomcat logs', + categories: ['web', 'security'], + uiInternalPath: '/app/home#/tutorial/tomcatLogs', + description: 'Collect Apache Tomcat logs over syslog or from a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/tomcat.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'tomcat', + isBeta: false, + }, + { + type: 'ui_link', + id: 'Zscaler Logs', + title: 'Zscaler Logs', + categories: ['network', 'security'], + uiInternalPath: '/app/home#/tutorial/zscalerLogs', + description: 'This is a module for receiving Zscaler NSS logs over Syslog or a file.', + icons: [ + { + type: 'svg', + src: '/dqo/plugins/home/assets/logos/zscaler.svg', + }, + ], + shipper: 'beats', + eprOverlap: 'zscaler', + isBeta: false, + }, + { + type: 'ui_link', + id: 'apm', + title: 'APM', + categories: ['web'], + uiInternalPath: '/app/home#/tutorial/apm', + description: 'Collect in-depth performance metrics and errors from inside your applications.', + icons: [ + { + type: 'eui', + src: 'apmApp', + }, + ], + shipper: 'tutorial', + isBeta: false, + eprOverlap: 'apm', + }, +]; diff --git a/src/plugins/custom_integrations/public/services/stub/index.ts b/src/plugins/custom_integrations/public/services/stub/index.ts new file mode 100644 index 0000000000000..fe7465949d565 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/stub/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, +} from '../../../../presentation_util/public'; + +import { CustomIntegrationsServices } from '..'; +import { findServiceFactory } from './find'; +import { platformServiceFactory } from './platform'; + +export { findServiceFactory } from './find'; +export { platformServiceFactory } from './platform'; + +export const providers: PluginServiceProviders = { + find: new PluginServiceProvider(findServiceFactory), + platform: new PluginServiceProvider(platformServiceFactory), +}; + +export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/custom_integrations/public/services/stub/platform.ts b/src/plugins/custom_integrations/public/services/stub/platform.ts new file mode 100644 index 0000000000000..60480f9905cb9 --- /dev/null +++ b/src/plugins/custom_integrations/public/services/stub/platform.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. + */ + +import { PluginServiceFactory } from '../../../../presentation_util/public'; + +import type { CustomIntegrationsPlatformService } from '../platform'; + +/** + * A type definition for a factory to produce the `CustomIntegrationsPlatformService` with stubbed output. + * @see /src/plugins/presentation_util/public/services/create/factory.ts + */ +export type CustomIntegrationsPlatformServiceFactory = + PluginServiceFactory; + +/** + * A factory to produce the `CustomIntegrationsPlatformService` with stubbed output. + */ +export const platformServiceFactory: CustomIntegrationsPlatformServiceFactory = () => ({ + getBasePath: () => '/basePath', + getAbsolutePath: (path: string): string => `/basePath${path}`, +}); diff --git a/src/plugins/custom_integrations/public/types.ts b/src/plugins/custom_integrations/public/types.ts index 9a12af767ecbc..946115329e2b5 100755 --- a/src/plugins/custom_integrations/public/types.ts +++ b/src/plugins/custom_integrations/public/types.ts @@ -6,14 +6,19 @@ * Side Public License, v 1. */ +import type { PresentationUtilPluginStart } from '../../presentation_util/public'; + import { CustomIntegration } from '../common'; export interface CustomIntegrationsSetup { getAppendCustomIntegrations: () => Promise; getReplacementCustomIntegrations: () => Promise; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface CustomIntegrationsStart {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface AppPluginStartDependencies {} +export interface CustomIntegrationsStart { + ContextProvider: React.FC; +} + +export interface CustomIntegrationsStartDependencies { + presentationUtil: PresentationUtilPluginStart; +} diff --git a/src/plugins/custom_integrations/server/language_clients/index.ts b/src/plugins/custom_integrations/server/language_clients/index.ts new file mode 100644 index 0000000000000..da61f804b4242 --- /dev/null +++ b/src/plugins/custom_integrations/server/language_clients/index.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup } from 'kibana/server'; +import { CustomIntegrationRegistry } from '../custom_integration_registry'; +import { CustomIntegrationIcon, PLUGIN_ID } from '../../common'; + +interface LanguageIntegration { + id: string; + title: string; + icon?: string; + euiIconName?: string; + description: string; + docUrlTemplate: string; +} + +const ELASTIC_WEBSITE_URL = 'https://www.elastic.co'; +const ELASTICSEARCH_CLIENT_URL = `${ELASTIC_WEBSITE_URL}/guide/en/elasticsearch/client`; +export const integrations: LanguageIntegration[] = [ + { + id: 'all', + title: i18n.translate('customIntegrations.languageclients.AllTitle', { + defaultMessage: 'Elasticsearch Clients', + }), + euiIconName: 'logoElasticsearch', + description: i18n.translate('customIntegrations.languageclients.AllDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official language clients.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/index.html`, + }, + { + id: 'javascript', + title: i18n.translate('customIntegrations.languageclients.JavascriptTitle', { + defaultMessage: 'Elasticsearch JavaScript Client', + }), + icon: 'nodejs.svg', + description: i18n.translate('customIntegrations.languageclients.JavascriptDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official Node.js client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/javascript-api/{branch}/introduction.html`, + }, + { + id: 'ruby', + title: i18n.translate('customIntegrations.languageclients.RubyTitle', { + defaultMessage: 'Elasticsearch Ruby Client', + }), + icon: 'ruby.svg', + description: i18n.translate('customIntegrations.languageclients.RubyDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official Ruby client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/ruby-api/{branch}/ruby_client.html`, + }, + { + id: 'go', + title: i18n.translate('customIntegrations.languageclients.GoTitle', { + defaultMessage: 'Elasticsearch Go Client', + }), + icon: 'go.svg', + description: i18n.translate('customIntegrations.languageclients.GoDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official Go client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/go-api/{branch}/overview.html`, + }, + { + id: 'dotnet', + title: i18n.translate('customIntegrations.languageclients.DotNetTitle', { + defaultMessage: 'Elasticsearch .NET Client', + }), + icon: 'dotnet.svg', + description: i18n.translate('customIntegrations.languageclients.DotNetDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official .NET client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/net-api/{branch}/index.html`, + }, + { + id: 'php', + title: i18n.translate('customIntegrations.languageclients.PhpTitle', { + defaultMessage: 'Elasticsearch PHP Client', + }), + icon: 'php.svg', + description: i18n.translate('customIntegrations.languageclients.PhpDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official .PHP client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/php-api/{branch}/index.html`, + }, + { + id: 'perl', + title: i18n.translate('customIntegrations.languageclients.PerlTitle', { + defaultMessage: 'Elasticsearch Perl Client', + }), + icon: 'perl.svg', + description: i18n.translate('customIntegrations.languageclients.PerlDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official Perl client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/perl-api/{branch}/index.html`, + }, + { + id: 'python', + title: i18n.translate('customIntegrations.languageclients.PythonTitle', { + defaultMessage: 'Elasticsearch Python Client', + }), + icon: 'python.svg', + description: i18n.translate('customIntegrations.languageclients.PythonDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official Python client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/python-api/{branch}/index.html`, + }, + { + id: 'rust', + title: i18n.translate('customIntegrations.languageclients.RustTitle', { + defaultMessage: 'Elasticsearch Rust Client', + }), + icon: 'rust.svg', + description: i18n.translate('customIntegrations.languageclients.RustDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official Rust client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/rust-api/{branch}/index.html`, + }, + { + id: 'java', + title: i18n.translate('customIntegrations.languageclients.JavaTitle', { + defaultMessage: 'Elasticsearch Java Client', + }), + icon: 'java.svg', + description: i18n.translate('customIntegrations.languageclients.JavaDescription', { + defaultMessage: + 'Start building your custom application on top of Elasticsearch with the official Java client.', + }), + docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/java-api-client/{branch}/index.html`, + }, +]; + +export function registerLanguageClients( + core: CoreSetup, + registry: CustomIntegrationRegistry, + branch: string +) { + integrations.forEach((integration: LanguageIntegration) => { + const icons: CustomIntegrationIcon[] = []; + if (integration.euiIconName) { + icons.push({ + type: 'eui', + src: integration.euiIconName, + }); + } else if (integration.icon) { + icons.push({ + type: 'svg', + src: core.http.basePath.prepend( + `/plugins/${PLUGIN_ID}/assets/language_clients/${integration.icon}` + ), + }); + } + + registry.registerCustomIntegration({ + id: `language_client.${integration.id}`, + title: integration.title, + description: integration.description, + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: integration.docUrlTemplate.replace('{branch}', branch), + isBeta: false, + icons, + categories: ['elastic_stack', 'custom', 'language_client'], + }); + }); +} diff --git a/src/plugins/custom_integrations/server/plugin.test.ts b/src/plugins/custom_integrations/server/plugin.test.ts index 424eedf0603cd..8dee81ba6cba3 100644 --- a/src/plugins/custom_integrations/server/plugin.test.ts +++ b/src/plugins/custom_integrations/server/plugin.test.ts @@ -22,9 +22,145 @@ describe('CustomIntegrationsPlugin', () => { initContext = coreMock.createPluginInitializerContext(); }); - test('wires up tutorials provider service and returns registerTutorial and addScopedTutorialContextFactory', () => { + test('should return setup contract', () => { const setup = new CustomIntegrationsPlugin(initContext).setup(mockCoreSetup); expect(setup).toHaveProperty('registerCustomIntegration'); + expect(setup).toHaveProperty('getAppendCustomIntegrations'); + }); + + test('should register language clients', () => { + const setup = new CustomIntegrationsPlugin(initContext).setup(mockCoreSetup); + expect(setup.getAppendCustomIntegrations()).toEqual([ + { + id: 'language_client.all', + title: 'Elasticsearch Clients', + description: + 'Start building your custom application on top of Elasticsearch with the official language clients.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: 'https://www.elastic.co/guide/en/elasticsearch/client/index.html', + isBeta: false, + icons: [{ type: 'eui', src: 'logoElasticsearch' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.javascript', + title: 'Elasticsearch JavaScript Client', + description: + 'Start building your custom application on top of Elasticsearch with the official Node.js client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/branch/introduction.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.ruby', + title: 'Elasticsearch Ruby Client', + description: + 'Start building your custom application on top of Elasticsearch with the official Ruby client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/ruby-api/branch/ruby_client.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.go', + title: 'Elasticsearch Go Client', + description: + 'Start building your custom application on top of Elasticsearch with the official Go client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/go-api/branch/overview.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.dotnet', + title: 'Elasticsearch .NET Client', + description: + 'Start building your custom application on top of Elasticsearch with the official .NET client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/net-api/branch/index.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.php', + title: 'Elasticsearch PHP Client', + description: + 'Start building your custom application on top of Elasticsearch with the official .PHP client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/php-api/branch/index.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.perl', + title: 'Elasticsearch Perl Client', + description: + 'Start building your custom application on top of Elasticsearch with the official Perl client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/perl-api/branch/index.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.python', + title: 'Elasticsearch Python Client', + description: + 'Start building your custom application on top of Elasticsearch with the official Python client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/python-api/branch/index.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.rust', + title: 'Elasticsearch Rust Client', + description: + 'Start building your custom application on top of Elasticsearch with the official Rust client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/rust-api/branch/index.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + { + id: 'language_client.java', + title: 'Elasticsearch Java Client', + description: + 'Start building your custom application on top of Elasticsearch with the official Java client.', + type: 'ui_link', + shipper: 'language_clients', + uiInternalPath: + 'https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/branch/index.html', + isBeta: false, + icons: [{ type: 'svg' }], + categories: ['elastic_stack', 'custom', 'language_client'], + }, + ]); }); }); }); diff --git a/src/plugins/custom_integrations/server/plugin.ts b/src/plugins/custom_integrations/server/plugin.ts index 099650ee15a05..330a1288d05a2 100755 --- a/src/plugins/custom_integrations/server/plugin.ts +++ b/src/plugins/custom_integrations/server/plugin.ts @@ -12,12 +12,14 @@ import { CustomIntegrationsPluginSetup, CustomIntegrationsPluginStart } from './ import { CustomIntegration } from '../common'; import { CustomIntegrationRegistry } from './custom_integration_registry'; import { defineRoutes } from './routes/define_routes'; +import { registerLanguageClients } from './language_clients'; export class CustomIntegrationsPlugin implements Plugin { private readonly logger: Logger; private readonly customIngegrationRegistry: CustomIntegrationRegistry; + private readonly branch: string; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); @@ -25,6 +27,7 @@ export class CustomIntegrationsPlugin this.logger, initializerContext.env.mode.dev ); + this.branch = initializerContext.env.packageInfo.branch; } public setup(core: CoreSetup) { @@ -33,6 +36,8 @@ export class CustomIntegrationsPlugin const router = core.http.createRouter(); defineRoutes(router, this.customIngegrationRegistry); + registerLanguageClients(core, this.customIngegrationRegistry, this.branch); + return { registerCustomIntegration: (integration: Omit) => { this.customIngegrationRegistry.registerCustomIntegration({ @@ -40,6 +45,9 @@ export class CustomIntegrationsPlugin ...integration, }); }, + getAppendCustomIntegrations: () => { + return this.customIngegrationRegistry.getAppendCustomIntegrations(); + }, } as CustomIntegrationsPluginSetup; } diff --git a/src/plugins/custom_integrations/storybook/decorator.tsx b/src/plugins/custom_integrations/storybook/decorator.tsx new file mode 100644 index 0000000000000..c5fea9615ee47 --- /dev/null +++ b/src/plugins/custom_integrations/storybook/decorator.tsx @@ -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 React from 'react'; + +import { DecoratorFn } from '@storybook/react'; +import { I18nProvider } from '@kbn/i18n/react'; + +import { PluginServiceRegistry } from '../../presentation_util/public'; + +import { pluginServices } from '../public/services'; +import { CustomIntegrationsServices } from '../public/services'; +import { providers } from '../public/services/storybook'; +import { EuiThemeProvider } from '../../kibana_react/common/eui_styled_components'; + +/** + * Returns a Storybook Decorator that provides both the `I18nProvider` and access to `PluginServices` + * for components rendered in Storybook. + */ +export const getCustomIntegrationsContextDecorator = + (): DecoratorFn => + (story, { globals }) => { + const ContextProvider = getCustomIntegrationsContextProvider(); + const darkMode = globals.euiTheme === 'v8.dark' || globals.euiTheme === 'v7.dark'; + + return ( + + + {story()} + + + ); + }; + +/** + * Prepares `PluginServices` for use in Storybook and returns a React `Context.Provider` element + * so components that access `PluginServices` can be rendered. + */ +export const getCustomIntegrationsContextProvider = () => { + const registry = new PluginServiceRegistry(providers); + pluginServices.setRegistry(registry.start({})); + return pluginServices.getContextProvider(); +}; diff --git a/src/core/server/legacy/index.ts b/src/plugins/custom_integrations/storybook/index.ts similarity index 65% rename from src/core/server/legacy/index.ts rename to src/plugins/custom_integrations/storybook/index.ts index 39ffef501a9ec..a9e34e1aeeb7e 100644 --- a/src/core/server/legacy/index.ts +++ b/src/plugins/custom_integrations/storybook/index.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ -/** @internal */ -export type { ILegacyService } from './legacy_service'; -export { LegacyService } from './legacy_service'; +export { + getCustomIntegrationsContextDecorator as getStorybookContextDecorator, + getCustomIntegrationsContextProvider as getStorybookContextProvider, +} from '../storybook/decorator'; diff --git a/packages/kbn-config/src/legacy/index.ts b/src/plugins/custom_integrations/storybook/main.ts similarity index 76% rename from packages/kbn-config/src/legacy/index.ts rename to src/plugins/custom_integrations/storybook/main.ts index f6906f81d1821..1261fe5a06f69 100644 --- a/packages/kbn-config/src/legacy/index.ts +++ b/src/plugins/custom_integrations/storybook/main.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -export { - LegacyObjectToConfigAdapter, - LegacyLoggingConfig, -} from './legacy_object_to_config_adapter'; +import { defaultConfig } from '@kbn/storybook'; + +module.exports = defaultConfig; diff --git a/src/plugins/custom_integrations/storybook/manager.ts b/src/plugins/custom_integrations/storybook/manager.ts new file mode 100644 index 0000000000000..99c01efdddfdc --- /dev/null +++ b/src/plugins/custom_integrations/storybook/manager.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. + */ + +import { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID } from '@storybook/addon-actions'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: 'Kibana Custom Integrations Storybook', + brandUrl: 'https://github.com/elastic/kibana/tree/master/src/plugins/custom_integrations', + }), + showPanel: true.valueOf, + selectedPanel: PANEL_ID, +}); diff --git a/src/plugins/custom_integrations/storybook/preview.tsx b/src/plugins/custom_integrations/storybook/preview.tsx new file mode 100644 index 0000000000000..c27390261c920 --- /dev/null +++ b/src/plugins/custom_integrations/storybook/preview.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 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 { Title, Subtitle, Description, Primary, Stories } from '@storybook/addon-docs/blocks'; + +import { getCustomIntegrationsContextDecorator } from './decorator'; + +export const decorators = [getCustomIntegrationsContextDecorator()]; + +export const parameters = { + docs: { + page: () => ( + <> + + <Subtitle /> + <Description /> + <Primary /> + <Stories /> + </> + ), + }, +}; diff --git a/src/plugins/custom_integrations/tsconfig.json b/src/plugins/custom_integrations/tsconfig.json index 2ce7bf9c8112c..ccb75c358611b 100644 --- a/src/plugins/custom_integrations/tsconfig.json +++ b/src/plugins/custom_integrations/tsconfig.json @@ -6,8 +6,15 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*"], + "include": [ + "../../../typings/**/*", + "common/**/*", + "public/**/*", + "server/**/*", + "storybook/**/*" + ], "references": [ - { "path": "../../core/tsconfig.json" } + { "path": "../../core/tsconfig.json" }, + { "path": "../presentation_util/tsconfig.json" } ] } diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx index 0ddd0902b719f..46ae4d9456d92 100644 --- a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -128,7 +128,10 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { name: titleInWizard || title, icon: icon as string, onClick: - group === VisGroups.AGGBASED ? createNewAggsBasedVis(visType) : createNewVisType(visType), + // not all the agg-based visualizations need to be created via the wizard + group === VisGroups.AGGBASED && visType.options.showIndexSelection + ? createNewAggsBasedVis(visType) + : createNewVisType(visType), 'data-test-subj': `visType-${name}`, toolTipContent: description, }; diff --git a/src/plugins/data/server/config_deprecations.test.ts b/src/plugins/data/server/config_deprecations.test.ts index 365c3b749f6c7..6c09b060aa763 100644 --- a/src/plugins/data/server/config_deprecations.test.ts +++ b/src/plugins/data/server/config_deprecations.test.ts @@ -9,9 +9,12 @@ import { cloneDeep } from 'lodash'; import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; +import { configDeprecationsMock } from '../../../core/server/mocks'; import { autocompleteConfigDeprecationProvider } from './config_deprecations'; +const deprecationContext = configDeprecationsMock.createContext(); + const applyConfigDeprecations = (settings: Record<string, any> = {}) => { const deprecations = autocompleteConfigDeprecationProvider(configDeprecationFactory); const deprecationMessages: string[] = []; @@ -20,6 +23,7 @@ const applyConfigDeprecations = (settings: Record<string, any> = {}) => { deprecations.map((deprecation) => ({ deprecation, path: '', + context: deprecationContext, })), () => ({ message }) => diff --git a/src/plugins/discover/public/application/apps/main/components/uninitialized/uninitialized.tsx b/src/plugins/discover/public/application/apps/main/components/uninitialized/uninitialized.tsx index c9e0c43900ba1..6c1b1bfc87d20 100644 --- a/src/plugins/discover/public/application/apps/main/components/uninitialized/uninitialized.tsx +++ b/src/plugins/discover/public/application/apps/main/components/uninitialized/uninitialized.tsx @@ -32,7 +32,7 @@ export const DiscoverUninitialized = ({ onRefresh }: Props) => { </p> } actions={ - <EuiButton color="primary" fill onClick={onRefresh}> + <EuiButton color="primary" fill onClick={onRefresh} data-test-subj="refreshDataButton"> <FormattedMessage id="discover.uninitializedRefreshButtonText" defaultMessage="Refresh data" diff --git a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx index 7ee9ab44f9a75..c7a38032ef405 100644 --- a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx +++ b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx @@ -38,7 +38,7 @@ export interface DiscoverMainProps { } export function DiscoverMainApp(props: DiscoverMainProps) { - const { services, history, indexPatternList } = props; + const { savedSearch, services, history, indexPatternList } = props; const { chrome, docLinks, uiSettings: config, data } = services; const navigateTo = useCallback( (path: string) => { @@ -46,7 +46,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) { }, [history] ); - const savedSearch = props.savedSearch; /** * State related logic diff --git a/src/plugins/discover/public/application/apps/main/discover_main_route.tsx b/src/plugins/discover/public/application/apps/main/discover_main_route.tsx index 5141908e44ade..a95668642558c 100644 --- a/src/plugins/discover/public/application/apps/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/apps/main/discover_main_route.tsx @@ -75,8 +75,6 @@ export function DiscoverMainRoute({ services, history }: DiscoverMainProps) { async function loadSavedSearch() { try { - // force a refresh if a given saved search without id was saved - setSavedSearch(undefined); const loadedSavedSearch = await services.getSavedSearchById(savedSearchId); const loadedIndexPattern = await loadDefaultOrCurrentIndexPattern(loadedSavedSearch); if (loadedSavedSearch && !loadedSavedSearch?.searchSource.getField('index')) { diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts index e11a9937111a1..223d896b16cd1 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts @@ -96,6 +96,7 @@ export function useDiscoverState({ useEffect(() => { const stopSync = stateContainer.initializeAndSync(indexPattern, filterManager, data); + return () => stopSync(); }, [stateContainer, filterManager, data, indexPattern]); @@ -209,16 +210,13 @@ export function useDiscoverState({ }, [config, data, savedSearch, reset, stateContainer]); /** - * Initial data fetching, also triggered when index pattern changes + * Trigger data fetching on indexPattern or savedSearch changes */ useEffect(() => { - if (!indexPattern) { - return; - } - if (initialFetchStatus === FetchStatus.LOADING) { + if (indexPattern) { refetch$.next(); } - }, [initialFetchStatus, refetch$, indexPattern]); + }, [initialFetchStatus, refetch$, indexPattern, savedSearch.id]); return { data$, diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts index 26f95afba5a93..d11c76283fedd 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts @@ -156,6 +156,7 @@ export const useSavedSearch = ({ refetch$, searchSessionManager, searchSource, + initialFetchStatus, }); const subscription = fetch$.subscribe((val) => { diff --git a/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts index aac6196e64f6f..528f0e74d3ed6 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import { merge } from 'rxjs'; -import { debounceTime, filter, tap } from 'rxjs/operators'; +import { debounceTime, filter, skip, tap } from 'rxjs/operators'; import { FetchStatus } from '../../../types'; import type { @@ -26,6 +26,7 @@ export function getFetch$({ main$, refetch$, searchSessionManager, + initialFetchStatus, }: { setAutoRefreshDone: (val: AutoRefreshDoneFn | undefined) => void; data: DataPublicPluginStart; @@ -33,10 +34,11 @@ export function getFetch$({ refetch$: DataRefetch$; searchSessionManager: DiscoverSearchSessionManager; searchSource: SearchSource; + initialFetchStatus: FetchStatus; }) { const { timefilter } = data.query.timefilter; const { filterManager } = data.query; - return merge( + let fetch$ = merge( refetch$, filterManager.getFetches$(), timefilter.getFetch$(), @@ -58,4 +60,13 @@ export function getFetch$({ data.query.queryString.getUpdates$(), searchSessionManager.newSearchSessionIdFromURL$.pipe(filter((sessionId) => !!sessionId)) ).pipe(debounceTime(100)); + + /** + * Skip initial fetch when discover:searchOnPageLoad is disabled. + */ + if (initialFetchStatus === FetchStatus.UNINITIALIZED) { + fetch$ = fetch$.pipe(skip(1)); + } + + return fetch$; } diff --git a/src/plugins/discover/public/application/apps/main/utils/get_fetch_observeable.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observeable.test.ts index 5f728b115b2e9..39873ff609d64 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_fetch_observeable.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observeable.test.ts @@ -58,6 +58,7 @@ describe('getFetchObservable', () => { data: createDataMock(new Subject(), new Subject(), new Subject(), new Subject()), searchSessionManager: searchSessionManagerMock.searchSessionManager, searchSource: savedSearchMock.searchSource, + initialFetchStatus: FetchStatus.LOADING, }); fetch$.subscribe(() => { @@ -81,6 +82,7 @@ describe('getFetchObservable', () => { data: dataMock, searchSessionManager: searchSessionManagerMock.searchSessionManager, searchSource: savedSearchMockWithTimeField.searchSource, + initialFetchStatus: FetchStatus.LOADING, }); const fetchfnMock = jest.fn(); diff --git a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.test.ts index 554aca6ddb8f1..04ee5f414e7f4 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.test.ts @@ -31,6 +31,7 @@ describe('getStateDefaults', () => { "index": "index-pattern-with-timefield-id", "interval": "auto", "query": undefined, + "savedQuery": undefined, "sort": Array [ Array [ "timestamp", @@ -59,6 +60,7 @@ describe('getStateDefaults', () => { "index": "the-index-pattern-id", "interval": "auto", "query": undefined, + "savedQuery": undefined, "sort": Array [], } `); diff --git a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts index 4061d9a61f0a3..cd23d52022374 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts @@ -47,6 +47,7 @@ export function getStateDefaults({ interval: 'auto', filters: cloneDeep(searchSource.getOwnField('filter')), hideChart: undefined, + savedQuery: undefined, } as AppState; if (savedSearch.grid) { defaultState.grid = savedSearch.grid; diff --git a/src/plugins/discover/public/application/apps/not_found/not_found_route.tsx b/src/plugins/discover/public/application/apps/not_found/not_found_route.tsx index cd16a820cc8f7..6b6ef584d07f1 100644 --- a/src/plugins/discover/public/application/apps/not_found/not_found_route.tsx +++ b/src/plugins/discover/public/application/apps/not_found/not_found_route.tsx @@ -9,6 +9,7 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiCallOut } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { Redirect } from 'react-router-dom'; import { toMountPoint } from '../../../../../kibana_react/public'; import { DiscoverServices } from '../../../build_services'; import { getUrlTracker } from '../../../kibana_services'; @@ -23,7 +24,8 @@ let bannerId: string | undefined; export function NotFoundRoute(props: NotFoundRouteProps) { const { services } = props; - const { urlForwarding } = services; + const { urlForwarding, core, history } = services; + const currentLocation = history().location.pathname; useEffect(() => { const path = window.location.hash.substr(1); @@ -34,14 +36,17 @@ export function NotFoundRoute(props: NotFoundRouteProps) { defaultMessage: 'Page not found', }); - bannerId = services.core.overlays.banners.replace( + bannerId = core.overlays.banners.replace( bannerId, toMountPoint( <EuiCallOut color="warning" iconType="iInCircle" title={bannerMessage}> - <p> + <p data-test-subj="invalidRouteMessage"> <FormattedMessage id="discover.noMatchRoute.bannerText" - defaultMessage="Invalid URL for Discover application." + defaultMessage="Discover application doesn't recognize this route: {route}" + values={{ + route: history().location.state.referrer, + }} /> </p> </EuiCallOut> @@ -51,10 +56,10 @@ export function NotFoundRoute(props: NotFoundRouteProps) { // hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around setTimeout(() => { if (bannerId) { - services.core.overlays.banners.remove(bannerId); + core.overlays.banners.remove(bannerId); } }, 15000); - }, [services.core.overlays.banners, services.history, urlForwarding]); + }, [core.overlays.banners, history, urlForwarding]); - return null; + return <Redirect to={{ pathname: '/', state: { referrer: currentLocation } }} />; } diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index a1d4b5b68d20d..06133fb2160c0 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -46,6 +46,7 @@ export abstract class Container< parent?: Container ) { super(input, output, parent); + this.getFactory = getFactory; // Currently required for using in storybook due to https://github.com/storybookjs/storybook/issues/13834 this.subscription = this.getInput$() // At each update event, get both the previous and current state .pipe(startWith(input), pairwise()) diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.ts b/src/plugins/es_ui_shared/public/request/use_request.test.ts index 68edde1336728..a6c22073dbc90 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.test.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.test.ts @@ -308,6 +308,24 @@ describe('useRequest hook', () => { expect(getSendRequestSpy().callCount).toBe(2); }); + it(`changing pollIntervalMs to undefined cancels the poll`, async () => { + const { setupErrorRequest, setErrorResponse, completeRequest, getSendRequestSpy } = helpers; + // Send initial request. + setupErrorRequest({ pollIntervalMs: REQUEST_TIME }); + + // Setting the poll to undefined will cancel subsequent requests. + setErrorResponse({ pollIntervalMs: undefined }); + + // Complete initial request. + await completeRequest(); + + // If there were another scheduled poll request, this would complete it. + await completeRequest(); + + // But because we canceled the poll, we only see 1 request instead of 2. + expect(getSendRequestSpy().callCount).toBe(1); + }); + it('when the path changes after a request is scheduled, the scheduled request is sent with that path', async () => { const { setupSuccessRequest, diff --git a/src/plugins/home/server/index.ts b/src/plugins/home/server/index.ts index 9523766596fed..c75ce4e83921c 100644 --- a/src/plugins/home/server/index.ts +++ b/src/plugins/home/server/index.ts @@ -18,9 +18,6 @@ export const config: PluginConfigDescriptor<ConfigSchema> = { disableWelcomeScreen: true, }, schema: configSchema, - deprecations: ({ renameFromRoot }) => [ - renameFromRoot('kibana.disableWelcomeScreen', 'home.disableWelcomeScreen'), - ], }; export const plugin = (initContext: PluginInitializerContext) => new HomeServerPlugin(initContext); diff --git a/src/plugins/kibana_react/public/page_template/page_template.tsx b/src/plugins/kibana_react/public/page_template/page_template.tsx index 1132d1dc6b4ed..cf2b27c3b00da 100644 --- a/src/plugins/kibana_react/public/page_template/page_template.tsx +++ b/src/plugins/kibana_react/public/page_template/page_template.tsx @@ -133,6 +133,7 @@ export const KibanaPageTemplate: FunctionComponent<KibanaPageTemplateProps> = ({ if (noDataConfig) { return ( <EuiPageTemplate + data-test-subj={rest['data-test-subj']} template={template} className={classes} pageSideBar={pageSideBar} 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 index b476244e5082f..954b12dba00f7 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/config_usage/README.md +++ b/src/plugins/kibana_usage_collection/server/collectors/config_usage/README.md @@ -55,7 +55,6 @@ when setting an exact config or its parent path to `false`. "server.port": 5603, "server.basePath": "[redacted]", "server.rewriteBasePath": true, - "logging.json": false, "usageCollection.uiCounters.debug": true } } diff --git a/src/plugins/newsfeed/server/config.ts b/src/plugins/newsfeed/server/config.ts index f8924706b751c..f14f3452761e1 100644 --- a/src/plugins/newsfeed/server/config.ts +++ b/src/plugins/newsfeed/server/config.ts @@ -11,7 +11,6 @@ import { NEWSFEED_DEFAULT_SERVICE_PATH, NEWSFEED_DEFAULT_SERVICE_BASE_URL, NEWSFEED_DEV_SERVICE_BASE_URL, - NEWSFEED_FALLBACK_LANGUAGE, } from '../common/constants'; export const configSchema = schema.object({ @@ -25,7 +24,6 @@ export const configSchema = schema.object({ schema.string({ defaultValue: NEWSFEED_DEV_SERVICE_BASE_URL }) ), }), - defaultLanguage: schema.string({ defaultValue: NEWSFEED_FALLBACK_LANGUAGE }), // TODO: Deprecate since no longer used mainInterval: schema.duration({ defaultValue: '2m' }), // (2min) How often to retry failed fetches, and/or check if newsfeed items need to be refreshed from remote fetchInterval: schema.duration({ defaultValue: '1d' }), // (1day) How often to fetch remote and reset the last fetched time }); diff --git a/src/plugins/newsfeed/server/index.ts b/src/plugins/newsfeed/server/index.ts index 460d48622af69..fefb725e2804e 100644 --- a/src/plugins/newsfeed/server/index.ts +++ b/src/plugins/newsfeed/server/index.ts @@ -17,7 +17,6 @@ export const config: PluginConfigDescriptor<NewsfeedConfigType> = { mainInterval: true, fetchInterval: true, }, - deprecations: ({ unused }) => [unused('defaultLanguage')], }; export function plugin() { diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts new file mode 100644 index 0000000000000..59e7a44a83a17 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.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. + */ + +import { InputControlFactory } from '../types'; +import { ControlsService } from '../controls_service'; +import { flightFields, getEuiSelectableOptions } from './flights'; +import { OptionsListEmbeddableFactory } from '../control_types/options_list'; + +export const getControlsServiceStub = () => { + const controlsServiceStub = new ControlsService(); + + const optionsListFactoryStub = new OptionsListEmbeddableFactory( + ({ field, search }) => + new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500)), + () => Promise.resolve(['demo data flights']), + () => Promise.resolve(flightFields) + ); + + // cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory + const optionsListControlFactory = optionsListFactoryStub as unknown as InputControlFactory; + optionsListControlFactory.getDefaultInput = () => ({}); + controlsServiceStub.registerInputControlType(optionsListControlFactory); + return controlsServiceStub; +}; diff --git a/src/plugins/presentation_util/public/components/input_controls/__stories__/decorators.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx similarity index 95% rename from src/plugins/presentation_util/public/components/input_controls/__stories__/decorators.tsx rename to src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx index 0aaa0e7a8a533..c5d3cf2c815be 100644 --- a/src/plugins/presentation_util/public/components/input_controls/__stories__/decorators.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx @@ -23,7 +23,7 @@ const panelStyle = { const kqlBarStyle = { background: bar, padding: 16, minHeight, fontStyle: 'italic' }; -const inputBarStyle = { background: '#fff', padding: 4, minHeight }; +const inputBarStyle = { background: '#fff', padding: 4 }; const layout = (OptionStory: Story) => ( <EuiFlexGroup style={{ background }} direction="column"> diff --git a/src/plugins/presentation_util/public/components/input_controls/__stories__/flights.ts b/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts similarity index 100% rename from src/plugins/presentation_util/public/components/input_controls/__stories__/flights.ts rename to src/plugins/presentation_util/public/components/controls/__stories__/flights.ts diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx new file mode 100644 index 0000000000000..2a463fece18da --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, useMemo } from 'react'; +import uuid from 'uuid'; + +import { decorators } from './decorators'; +import { providers } from '../../../services/storybook'; +import { getControlsServiceStub } from './controls_service_stub'; +import { ControlGroupContainerFactory } from '../control_group/control_group_container_factory'; + +export default { + title: 'Controls', + description: '', + decorators, +}; + +const ControlGroupStoryComponent = () => { + const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []); + + providers.overlays.start({}); + const overlays = providers.overlays.getService(); + + const controlsServiceStub = getControlsServiceStub(); + + useEffect(() => { + (async () => { + const factory = new ControlGroupContainerFactory(controlsServiceStub, overlays); + const controlGroupContainerEmbeddable = await factory.create({ + inheritParentState: { + useQuery: false, + useFilters: false, + useTimerange: false, + }, + controlStyle: 'oneLine', + id: uuid.v4(), + panels: {}, + }); + if (controlGroupContainerEmbeddable && embeddableRoot.current) { + controlGroupContainerEmbeddable.render(embeddableRoot.current); + } + })(); + }, [embeddableRoot, controlsServiceStub, overlays]); + + return <div ref={embeddableRoot} />; +}; + +export const ControlGroupStory = () => <ControlGroupStoryComponent />; diff --git a/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_component.tsx b/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_component.tsx new file mode 100644 index 0000000000000..240beea13b0e2 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_component.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { + EuiButtonIcon, + EuiFormControlLayout, + EuiFormLabel, + EuiFormRow, + EuiToolTip, +} from '@elastic/eui'; +import { ControlGroupContainer } from '../control_group/control_group_container'; +import { useChildEmbeddable } from '../hooks/use_child_embeddable'; +import { ControlStyle } from '../types'; +import { ControlFrameStrings } from './control_frame_strings'; + +export interface ControlFrameProps { + container: ControlGroupContainer; + customPrepend?: JSX.Element; + controlStyle: ControlStyle; + enableActions?: boolean; + onRemove?: () => void; + embeddableId: string; + onEdit?: () => void; +} + +export const ControlFrame = ({ + customPrepend, + enableActions, + embeddableId, + controlStyle, + container, + onRemove, + onEdit, +}: ControlFrameProps) => { + const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []); + const embeddable = useChildEmbeddable({ container, embeddableId }); + + const [title, setTitle] = useState<string>(); + + const usingTwoLineLayout = controlStyle === 'twoLine'; + + useEffect(() => { + if (embeddableRoot.current && embeddable) { + embeddable.render(embeddableRoot.current); + } + const subscription = embeddable?.getInput$().subscribe((newInput) => setTitle(newInput.title)); + return () => subscription?.unsubscribe(); + }, [embeddable, embeddableRoot]); + + const floatingActions = ( + <div + className={classNames('controlFrame--floatingActions', { + 'controlFrame--floatingActions-twoLine': usingTwoLineLayout, + 'controlFrame--floatingActions-oneLine': !usingTwoLineLayout, + })} + > + <EuiToolTip content={ControlFrameStrings.floatingActions.getEditButtonTitle()}> + <EuiButtonIcon + aria-label={ControlFrameStrings.floatingActions.getEditButtonTitle()} + iconType="pencil" + onClick={onEdit} + color="text" + /> + </EuiToolTip> + <EuiToolTip content={ControlFrameStrings.floatingActions.getRemoveButtonTitle()}> + <EuiButtonIcon + aria-label={ControlFrameStrings.floatingActions.getRemoveButtonTitle()} + onClick={onRemove} + iconType="cross" + color="danger" + /> + </EuiToolTip> + </div> + ); + + const form = ( + <EuiFormControlLayout + className={'controlFrame--formControlLayout'} + fullWidth + prepend={ + <> + {customPrepend ?? null} + {usingTwoLineLayout ? undefined : ( + <EuiFormLabel className="controlFrame--formControlLayout__label" htmlFor={embeddableId}> + {title} + </EuiFormLabel> + )} + </> + } + > + <div + className={classNames('controlFrame--control', { + 'controlFrame--twoLine': controlStyle === 'twoLine', + 'controlFrame--oneLine': controlStyle === 'oneLine', + })} + id={`controlFrame--${embeddableId}`} + ref={embeddableRoot} + /> + </EuiFormControlLayout> + ); + + return ( + <> + {enableActions && floatingActions} + <EuiFormRow fullWidth label={usingTwoLineLayout ? title : undefined}> + {form} + </EuiFormRow> + </> + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_strings.ts b/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_strings.ts new file mode 100644 index 0000000000000..5f9e89aa797cb --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_strings.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const ControlFrameStrings = { + floatingActions: { + getEditButtonTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', { + defaultMessage: 'Manage control', + }), + getRemoveButtonTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.removeTitle', { + defaultMessage: 'Remove control', + }), + }, +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx new file mode 100644 index 0000000000000..d683c0749d98d --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx @@ -0,0 +1,163 @@ +/* + * 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 '../control_group.scss'; + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import React, { useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { + arrayMove, + SortableContext, + rectSortingStrategy, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { + closestCenter, + DndContext, + DragEndEvent, + DragOverlay, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + LayoutMeasuringStrategy, +} from '@dnd-kit/core'; + +import { ControlGroupStrings } from '../control_group_strings'; +import { ControlGroupContainer } from '../control_group_container'; +import { ControlClone, SortableControl } from './control_group_sortable_item'; +import { OPTIONS_LIST_CONTROL } from '../../control_types/options_list/options_list_embeddable'; + +interface ControlGroupProps { + controlGroupContainer: ControlGroupContainer; +} + +export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => { + const [controlIds, setControlIds] = useState<string[]>([]); + + // sync controlIds every time input panels change + useEffect(() => { + const subscription = controlGroupContainer.getInput$().subscribe(() => { + setControlIds((currentIds) => { + // sync control Ids with panels from container input. + const { panels } = controlGroupContainer.getInput(); + const newIds: string[] = []; + const allIds = [...currentIds, ...Object.keys(panels)]; + allIds.forEach((id) => { + const currentIndex = currentIds.indexOf(id); + if (!panels[id] && currentIndex !== -1) { + currentIds.splice(currentIndex, 1); + } + if (currentIndex === -1 && Boolean(panels[id])) { + newIds.push(id); + } + }); + return [...currentIds, ...newIds]; + }); + }); + return () => subscription.unsubscribe(); + }, [controlGroupContainer]); + + const [draggingId, setDraggingId] = useState<string | null>(null); + + const draggingIndex = useMemo( + () => (draggingId ? controlIds.indexOf(draggingId) : -1), + [controlIds, draggingId] + ); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const onDragEnd = ({ over }: DragEndEvent) => { + if (over) { + const overIndex = controlIds.indexOf(over.id); + if (draggingIndex !== overIndex) { + const newIndex = overIndex; + setControlIds((currentControlIds) => arrayMove(currentControlIds, draggingIndex, newIndex)); + } + } + setDraggingId(null); + }; + + return ( + <EuiFlexGroup wrap={false} direction="row" alignItems="center" className="superWrapper"> + <EuiFlexItem> + <DndContext + onDragStart={({ active }) => setDraggingId(active.id)} + onDragEnd={onDragEnd} + onDragCancel={() => setDraggingId(null)} + sensors={sensors} + collisionDetection={closestCenter} + layoutMeasuring={{ + strategy: LayoutMeasuringStrategy.Always, + }} + > + <SortableContext items={controlIds} strategy={rectSortingStrategy}> + <EuiFlexGroup + className={classNames('controlGroup', { 'controlGroup-isDragging': draggingId })} + alignItems="center" + gutterSize={'m'} + wrap={true} + > + {controlIds.map((controlId, index) => ( + <SortableControl + onEdit={() => controlGroupContainer.editControl(controlId)} + onRemove={() => controlGroupContainer.removeEmbeddable(controlId)} + dragInfo={{ index, draggingIndex }} + container={controlGroupContainer} + controlStyle={controlGroupContainer.getInput().controlStyle} + embeddableId={controlId} + width={controlGroupContainer.getInput().panels[controlId].width} + key={controlId} + /> + ))} + </EuiFlexGroup> + </SortableContext> + <DragOverlay> + {draggingId ? ( + <ControlClone + width={controlGroupContainer.getInput().panels[draggingId].width} + embeddableId={draggingId} + container={controlGroupContainer} + /> + ) : null} + </DragOverlay> + </DndContext> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="center" direction="row" gutterSize="xs"> + <EuiFlexItem> + <EuiToolTip content={ControlGroupStrings.management.getManageButtonTitle()}> + <EuiButtonIcon + aria-label={ControlGroupStrings.management.getManageButtonTitle()} + iconType="gear" + color="text" + data-test-subj="inputControlsSortingButton" + onClick={controlGroupContainer.editControlGroup} + /> + </EuiToolTip> + </EuiFlexItem> + <EuiFlexItem> + <EuiToolTip content={ControlGroupStrings.management.getAddControlTitle()}> + <EuiButtonIcon + aria-label={ControlGroupStrings.management.getManageButtonTitle()} + iconType="plus" + color="text" + data-test-subj="inputControlsSortingButton" + onClick={() => controlGroupContainer.createNewControl(OPTIONS_LIST_CONTROL)} // use popover when there are multiple types of control + /> + </EuiToolTip> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx new file mode 100644 index 0000000000000..3ae171a588da4 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx @@ -0,0 +1,151 @@ +/* + * 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 { EuiFlexItem, EuiFormLabel, EuiIcon, EuiFlexGroup } from '@elastic/eui'; +import React, { forwardRef, HTMLAttributes } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import classNames from 'classnames'; + +import { ControlWidth } from '../../types'; +import { ControlGroupContainer } from '../control_group_container'; +import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; +import { ControlFrame, ControlFrameProps } from '../../control_frame/control_frame_component'; + +interface DragInfo { + isOver?: boolean; + isDragging?: boolean; + draggingIndex?: number; + index?: number; +} + +export type SortableControlProps = ControlFrameProps & { + dragInfo: DragInfo; + width: ControlWidth; +}; + +/** + * A sortable wrapper around the generic control frame. + */ +export const SortableControl = (frameProps: SortableControlProps) => { + const { embeddableId } = frameProps; + const { over, listeners, isSorting, transform, transition, attributes, isDragging, setNodeRef } = + useSortable({ + id: embeddableId, + animateLayoutChanges: () => true, + }); + + frameProps.dragInfo = { ...frameProps.dragInfo, isOver: over?.id === embeddableId, isDragging }; + + return ( + <SortableControlInner + key={embeddableId} + ref={setNodeRef} + {...frameProps} + {...attributes} + {...listeners} + style={{ + transition: transition ?? undefined, + transform: isSorting ? undefined : CSS.Translate.toString(transform), + }} + /> + ); +}; + +const SortableControlInner = forwardRef< + HTMLButtonElement, + SortableControlProps & { style: HTMLAttributes<HTMLButtonElement>['style'] } +>( + ( + { + embeddableId, + controlStyle, + container, + dragInfo, + onRemove, + onEdit, + style, + width, + ...dragHandleProps + }, + dragHandleRef + ) => { + const { isOver, isDragging, draggingIndex, index } = dragInfo; + + const dragHandle = ( + <button ref={dragHandleRef} {...dragHandleProps} className="controlFrame--dragHandle"> + <EuiIcon type="grabHorizontal" /> + </button> + ); + + return ( + <EuiFlexItem + grow={width === 'auto'} + className={classNames('controlFrame--wrapper', { + 'controlFrame--wrapper-isDragging': isDragging, + 'controlFrame--wrapper-small': width === 'small', + 'controlFrame--wrapper-medium': width === 'medium', + 'controlFrame--wrapper-large': width === 'large', + 'controlFrame--wrapper-insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1), + 'controlFrame--wrapper-insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1), + })} + style={style} + > + <ControlFrame + enableActions={draggingIndex === -1} + controlStyle={controlStyle} + embeddableId={embeddableId} + customPrepend={dragHandle} + container={container} + onRemove={onRemove} + onEdit={onEdit} + /> + </EuiFlexItem> + ); + } +); + +/** + * A simplified clone version of the control which is dragged. This version only shows + * the title, because individual controls can be any size, and dragging a wide item + * can be quite cumbersome. + */ +export const ControlClone = ({ + embeddableId, + container, + width, +}: { + embeddableId: string; + container: ControlGroupContainer; + width: ControlWidth; +}) => { + const embeddable = useChildEmbeddable({ embeddableId, container }); + const layout = container.getInput().controlStyle; + return ( + <EuiFlexItem + className={classNames('controlFrame--cloneWrapper', { + 'controlFrame--cloneWrapper-small': width === 'small', + 'controlFrame--cloneWrapper-medium': width === 'medium', + 'controlFrame--cloneWrapper-large': width === 'large', + 'controlFrame--cloneWrapper-twoLine': layout === 'twoLine', + })} + > + {layout === 'twoLine' ? ( + <EuiFormLabel>{embeddable?.getInput().title}</EuiFormLabel> + ) : undefined} + <EuiFlexGroup gutterSize="none" className={'controlFrame--draggable'}> + <EuiFlexItem grow={false}> + <EuiIcon type="grabHorizontal" className="controlFrame--dragHandle" /> + </EuiFlexItem> + {container.getInput().controlStyle === 'oneLine' ? ( + <EuiFlexItem>{embeddable?.getInput().title}</EuiFlexItem> + ) : undefined} + </EuiFlexGroup> + </EuiFlexItem> + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss new file mode 100644 index 0000000000000..f49efa7aab043 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss @@ -0,0 +1,184 @@ +$smallControl: $euiSize * 14; +$mediumControl: $euiSize * 25; +$largeControl: $euiSize * 50; +$controlMinWidth: $euiSize * 14; + +.controlGroup { + margin-left: $euiSizeXS; + overflow-x: clip; // sometimes when using auto width, removing a control can cause a horizontal scrollbar to appear. + min-height: $euiSize * 4; + padding: $euiSize 0; +} + +.controlFrame--cloneWrapper { + width: max-content; + + .euiFormLabel { + padding-bottom: $euiSizeXS; + } + + &-small { + width: $smallControl; + } + + &-medium { + width: $mediumControl; + } + + &-large { + width: $largeControl; + } + + &-twoLine { + margin-top: -$euiSize * 1.25; + } + + .euiFormLabel, div { + cursor: grabbing !important; // prevents cursor flickering while dragging the clone + } + + .controlFrame--draggable { + cursor: grabbing; + height: $euiButtonHeight; + align-items: center; + border-radius: $euiBorderRadius; + @include euiFontSizeS; + font-weight: $euiFontWeightSemiBold; + @include euiFormControlDefaultShadow; + background-color: $euiFormInputGroupLabelBackground; + min-width: $controlMinWidth; + } + + .controlFrame--formControlLayout, .controlFrame--draggable { + &-clone { + box-shadow: 0 0 0 1px $euiShadowColor, + 0 1px 6px 0 $euiShadowColor; + cursor: grabbing !important; + } + + .controlFrame--dragHandle { + cursor: grabbing; + } + } +} + +.controlFrame--wrapper { + flex-basis: auto; + position: relative; + display: block; + + .controlFrame--formControlLayout { + width: 100%; + min-width: $controlMinWidth; + transition:background-color .1s, color .1s; + + &__label { + @include euiTextTruncate; + max-width: 50%; + } + + &:not(.controlFrame--formControlLayout-clone) { + .controlFrame--dragHandle { + cursor: grab; + } + } + + .controlFrame--control { + height: 100%; + transition: opacity .1s; + + &.controlFrame--twoLine { + width: 100%; + } + } + } + + &-small { + width: $smallControl; + } + + &-medium { + width: $mediumControl; + } + + &-large { + width: $largeControl; + } + + &-insertBefore, + &-insertAfter { + .controlFrame--formControlLayout:after { + content: ''; + position: absolute; + background-color: transparentize($euiColorPrimary, .5); + border-radius: $euiBorderRadius; + top: 0; + bottom: 0; + width: 2px; + } + } + + &-insertBefore { + .controlFrame--formControlLayout:after { + left: -$euiSizeS; + } + } + + &-insertAfter { + .controlFrame--formControlLayout:after { + right: -$euiSizeS; + } + } + + .controlFrame--floatingActions { + visibility: hidden; + opacity: 0; + + // slower transition on hover leave in case the user accidentally stops hover + transition: visibility .3s, opacity .3s; + + z-index: 1; + position: absolute; + + &-oneLine { + right:$euiSizeXS; + top: -$euiSizeL; + padding: $euiSizeXS; + border-radius: $euiBorderRadius; + background-color: $euiColorEmptyShade; + box-shadow: 0 0 0 1pt $euiColorLightShade; + } + + &-twoLine { + right:$euiSizeXS; + top: -$euiSizeXS; + } + } + + &:hover { + .controlFrame--floatingActions { + transition:visibility .1s, opacity .1s; + visibility: visible; + opacity: 1; + } + } + + &-isDragging { + .euiFormRow__labelWrapper { + opacity: 0; + } + .controlFrame--formControlLayout { + background-color: $euiColorEmptyShade !important; + color: transparent !important; + box-shadow: none; + + .euiFormLabel { + opacity: 0; + } + + .controlFrame--control { + opacity: 0; + } + } + } +} \ No newline at end of file diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.ts new file mode 100644 index 0000000000000..3c22b1ffbcd23 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.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 { ControlWidth } from '../types'; +import { ControlGroupStrings } from './control_group_strings'; + +export const CONTROL_GROUP_TYPE = 'control_group'; + +export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; + +export const CONTROL_WIDTH_OPTIONS = [ + { + id: `auto`, + label: ControlGroupStrings.management.controlWidth.getAutoWidthTitle(), + }, + { + id: `small`, + label: ControlGroupStrings.management.controlWidth.getSmallWidthTitle(), + }, + { + id: `medium`, + label: ControlGroupStrings.management.controlWidth.getMediumWidthTitle(), + }, + { + id: `large`, + label: ControlGroupStrings.management.controlWidth.getLargeWidthTitle(), + }, +]; + +export const CONTROL_LAYOUT_OPTIONS = [ + { + id: `oneLine`, + label: ControlGroupStrings.management.controlStyle.getSingleLineTitle(), + }, + { + id: `twoLine`, + label: ControlGroupStrings.management.controlStyle.getTwoLineTitle(), + }, +]; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_container.tsx b/src/plugins/presentation_util/public/components/controls/control_group/control_group_container.tsx new file mode 100644 index 0000000000000..03249889dfdea --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_container.tsx @@ -0,0 +1,224 @@ +/* + * 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 { cloneDeep } from 'lodash'; + +import { + Container, + EmbeddableFactory, + EmbeddableFactoryNotFoundError, +} from '../../../../../embeddable/public'; +import { + InputControlEmbeddable, + InputControlInput, + InputControlOutput, + IEditableControlFactory, + ControlWidth, +} from '../types'; +import { ControlsService } from '../controls_service'; +import { ControlGroupInput, ControlPanelState } from './types'; +import { ManageControlComponent } from './editor/manage_control'; +import { toMountPoint } from '../../../../../kibana_react/public'; +import { ControlGroup } from './component/control_group_component'; +import { PresentationOverlaysService } from '../../../services/overlays'; +import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_WIDTH } from './control_group_constants'; +import { ManageControlGroup } from './editor/manage_control_group_component'; +import { OverlayRef } from '../../../../../../core/public'; +import { ControlGroupStrings } from './control_group_strings'; + +export class ControlGroupContainer extends Container<InputControlInput, ControlGroupInput> { + public readonly type = CONTROL_GROUP_TYPE; + + private nextControlWidth: ControlWidth = DEFAULT_CONTROL_WIDTH; + + constructor( + initialInput: ControlGroupInput, + private readonly controlsService: ControlsService, + private readonly overlays: PresentationOverlaysService, + parent?: Container + ) { + super(initialInput, { embeddableLoaded: {} }, controlsService.getControlFactory, parent); + this.overlays = overlays; + this.controlsService = controlsService; + } + + protected createNewPanelState<TEmbeddableInput extends InputControlInput = InputControlInput>( + factory: EmbeddableFactory<InputControlInput, InputControlOutput, InputControlEmbeddable>, + partial: Partial<TEmbeddableInput> = {} + ): ControlPanelState<TEmbeddableInput> { + const panelState = super.createNewPanelState(factory, partial); + return { + order: 1, + width: this.nextControlWidth, + ...panelState, + } as ControlPanelState<TEmbeddableInput>; + } + + protected getInheritedInput(id: string): InputControlInput { + const { filters, query, timeRange, inheritParentState } = this.getInput(); + return { + filters: inheritParentState.useFilters ? filters : undefined, + query: inheritParentState.useQuery ? query : undefined, + timeRange: inheritParentState.useTimerange ? timeRange : undefined, + id, + }; + } + + public createNewControl = async (type: string) => { + const factory = this.controlsService.getControlFactory(type); + if (!factory) throw new EmbeddableFactoryNotFoundError(type); + + const initialInputPromise = new Promise<Omit<InputControlInput, 'id'>>((resolve, reject) => { + let inputToReturn: Partial<InputControlInput> = {}; + + const onCancel = (ref: OverlayRef) => { + this.overlays + .openConfirm(ControlGroupStrings.management.discardNewControl.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.discardNewControl.getConfirm(), + cancelButtonText: ControlGroupStrings.management.discardNewControl.getCancel(), + title: ControlGroupStrings.management.discardNewControl.getTitle(), + buttonColor: 'danger', + }) + .then((confirmed) => { + if (confirmed) { + reject(); + ref.close(); + } + }); + }; + + const flyoutInstance = this.overlays.openFlyout( + toMountPoint( + <ManageControlComponent + width={this.nextControlWidth} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + updateWidth={(newWidth) => (this.nextControlWidth = newWidth)} + controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ + onChange: (partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }, + })} + onSave={() => { + resolve(inputToReturn); + flyoutInstance.close(); + }} + onCancel={() => onCancel(flyoutInstance)} + /> + ), + { + onClose: (flyout) => onCancel(flyout), + } + ); + }); + initialInputPromise.then( + async (explicitInput) => { + await this.addNewEmbeddable(type, explicitInput); + }, + () => {} // swallow promise rejection because it can be part of normal flow + ); + }; + + public editControl = async (embeddableId: string) => { + const panel = this.getInput().panels[embeddableId]; + const factory = this.getFactory(panel.type); + const embeddable = await this.untilEmbeddableLoaded(embeddableId); + + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + + const initialExplicitInput = cloneDeep(panel.explicitInput); + const initialWidth = panel.width; + + const onCancel = (ref: OverlayRef) => { + this.overlays + .openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), + cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), + title: ControlGroupStrings.management.discardChanges.getTitle(), + buttonColor: 'danger', + }) + .then((confirmed) => { + if (confirmed) { + embeddable.updateInput(initialExplicitInput); + this.updateInput({ + panels: { + ...this.getInput().panels, + [embeddableId]: { ...this.getInput().panels[embeddableId], width: initialWidth }, + }, + }); + ref.close(); + } + }); + }; + + const flyoutInstance = this.overlays.openFlyout( + toMountPoint( + <ManageControlComponent + width={panel.width} + title={embeddable.getTitle()} + removeControl={() => this.removeEmbeddable(embeddableId)} + updateTitle={(newTitle) => embeddable.updateInput({ title: newTitle })} + controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ + onChange: (partialInput) => embeddable.updateInput(partialInput), + initialInput: embeddable.getInput(), + })} + onCancel={() => onCancel(flyoutInstance)} + onSave={() => flyoutInstance.close()} + updateWidth={(newWidth) => + this.updateInput({ + panels: { + ...this.getInput().panels, + [embeddableId]: { ...this.getInput().panels[embeddableId], width: newWidth }, + }, + }) + } + /> + ), + { + onClose: (flyout) => onCancel(flyout), + } + ); + }; + + public editControlGroup = () => { + const flyoutInstance = this.overlays.openFlyout( + toMountPoint( + <ManageControlGroup + controlStyle={this.getInput().controlStyle} + setControlStyle={(newStyle) => this.updateInput({ controlStyle: newStyle })} + deleteAllEmbeddables={() => { + this.overlays + .openConfirm(ControlGroupStrings.management.deleteAllControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteAllControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteAllControls.getCancel(), + title: ControlGroupStrings.management.deleteAllControls.getTitle(), + buttonColor: 'danger', + }) + .then((confirmed) => { + if (confirmed) { + Object.keys(this.getInput().panels).forEach((id) => this.removeEmbeddable(id)); + flyoutInstance.close(); + } + }); + }} + setAllPanelWidths={(newWidth) => { + const newPanels = cloneDeep(this.getInput().panels); + Object.values(newPanels).forEach((panel) => (panel.width = newWidth)); + this.updateInput({ panels: { ...newPanels, ...newPanels } }); + }} + panels={this.getInput().panels} + /> + ) + ); + }; + + public render(dom: HTMLElement) { + ReactDOM.render(<ControlGroup controlGroupContainer={this} />, dom); + } +} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_container_factory.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_container_factory.ts new file mode 100644 index 0000000000000..97ef48e6b240c --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_container_factory.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. + */ + +/* + * 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 { + Container, + ContainerOutput, + EmbeddableFactory, + EmbeddableFactoryDefinition, + ErrorEmbeddable, +} from '../../../../../embeddable/public'; +import { ControlGroupInput } from './types'; +import { ControlsService } from '../controls_service'; +import { ControlGroupStrings } from './control_group_strings'; +import { CONTROL_GROUP_TYPE } from './control_group_constants'; +import { ControlGroupContainer } from './control_group_container'; +import { PresentationOverlaysService } from '../../../services/overlays'; + +export type DashboardContainerFactory = EmbeddableFactory< + ControlGroupInput, + ContainerOutput, + ControlGroupContainer +>; +export class ControlGroupContainerFactory + implements EmbeddableFactoryDefinition<ControlGroupInput, ContainerOutput, ControlGroupContainer> +{ + public readonly isContainerType = true; + public readonly type = CONTROL_GROUP_TYPE; + public readonly controlsService: ControlsService; + private readonly overlays: PresentationOverlaysService; + + constructor(controlsService: ControlsService, overlays: PresentationOverlaysService) { + this.overlays = overlays; + this.controlsService = controlsService; + } + + public isEditable = async () => false; + + public readonly getDisplayName = () => { + return ControlGroupStrings.getEmbeddableTitle(); + }; + + public getDefaultInput(): Partial<ControlGroupInput> { + return { + panels: {}, + inheritParentState: { + useFilters: true, + useQuery: true, + useTimerange: true, + }, + }; + } + + public create = async ( + initialInput: ControlGroupInput, + parent?: Container + ): Promise<ControlGroupContainer | ErrorEmbeddable> => { + return new ControlGroupContainer(initialInput, this.controlsService, this.overlays, parent); + }; +} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts new file mode 100644 index 0000000000000..78e50d8651931 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const ControlGroupStrings = { + getEmbeddableTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.title', { + defaultMessage: 'Control group', + }), + manageControl: { + getFlyoutTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', { + defaultMessage: 'Manage control', + }), + getTitleInputTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.titleInputTitle', { + defaultMessage: 'Title', + }), + getWidthInputTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.widthInputTitle', { + defaultMessage: 'Control width', + }), + getSaveChangesTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.saveChangesTitle', { + defaultMessage: 'Save and close', + }), + getCancelTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.cancelTitle', { + defaultMessage: 'Cancel', + }), + }, + management: { + getAddControlTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.addControl', { + defaultMessage: 'Add control', + }), + getManageButtonTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.buttonTitle', { + defaultMessage: 'Manage controls', + }), + getFlyoutTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.flyoutTitle', { + defaultMessage: 'Manage controls', + }), + getDesignTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.designTitle', { + defaultMessage: 'Design', + }), + getWidthTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.widthTitle', { + defaultMessage: 'Width', + }), + getLayoutTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.layoutTitle', { + defaultMessage: 'Layout', + }), + getDeleteButtonTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.delete', { + defaultMessage: 'Delete control', + }), + getDeleteAllButtonTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll', { + defaultMessage: 'Delete all', + }), + controlWidth: { + getChangeAllControlWidthsTitle: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.management.layout.changeAllControlWidths', + { + defaultMessage: 'Set width for all controls', + } + ), + getWidthSwitchLegend: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.management.layout.controlWidthLegend', + { + defaultMessage: 'Change individual control width', + } + ), + getAutoWidthTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.auto', { + defaultMessage: 'Auto', + }), + getSmallWidthTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.small', { + defaultMessage: 'Small', + }), + getMediumWidthTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.medium', { + defaultMessage: 'Medium', + }), + getLargeWidthTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.large', { + defaultMessage: 'Large', + }), + }, + controlStyle: { + getDesignSwitchLegend: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.management.layout.designSwitchLegend', + { + defaultMessage: 'Switch control designs', + } + ), + getSingleLineTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.singleLine', { + defaultMessage: 'Single line layout', + }), + getTwoLineTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.twoLine', { + defaultMessage: 'Two line layout', + }), + }, + deleteAllControls: { + getTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.title', { + defaultMessage: 'Delete all?', + }), + getSubtitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.sub', { + defaultMessage: 'Controls are not recoverable once removed.', + }), + getConfirm: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.confirm', { + defaultMessage: 'Delete', + }), + getCancel: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.cancel', { + defaultMessage: 'Cancel', + }), + }, + discardChanges: { + getTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.title', { + defaultMessage: 'Discard?', + }), + getSubtitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', { + defaultMessage: + 'Discard changes to this control? Controls are not recoverable once removed.', + }), + getConfirm: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', { + defaultMessage: 'Discard', + }), + getCancel: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.cancel', { + defaultMessage: 'Cancel', + }), + }, + discardNewControl: { + getTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.title', { + defaultMessage: 'Discard?', + }), + getSubtitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', { + defaultMessage: 'Discard new control? Controls are not recoverable once removed.', + }), + getConfirm: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', { + defaultMessage: 'Discard', + }), + getCancel: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.cancel', { + defaultMessage: 'Cancel', + }), + }, + }, +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control.tsx new file mode 100644 index 0000000000000..6d80a6e0b31f6 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control.tsx @@ -0,0 +1,150 @@ +/* + * 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. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, useState } from 'react'; +import { + EuiFlyoutHeader, + EuiButtonGroup, + EuiFlyoutBody, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiFieldText, + EuiFlyoutFooter, + EuiButton, + EuiFormRow, + EuiForm, + EuiButtonEmpty, + EuiSpacer, +} from '@elastic/eui'; + +import { ControlGroupStrings } from '../control_group_strings'; +import { ControlEditorComponent, ControlWidth } from '../../types'; +import { CONTROL_WIDTH_OPTIONS } from '../control_group_constants'; + +interface ManageControlProps { + title?: string; + onSave: () => void; + width: ControlWidth; + onCancel: () => void; + removeControl?: () => void; + controlEditorComponent?: ControlEditorComponent; + updateTitle: (title: string) => void; + updateWidth: (newWidth: ControlWidth) => void; +} + +export const ManageControlComponent = ({ + controlEditorComponent, + removeControl, + updateTitle, + updateWidth, + onCancel, + onSave, + title, + width, +}: ManageControlProps) => { + const [currentTitle, setCurrentTitle] = useState(title); + const [currentWidth, setCurrentWidth] = useState(width); + + const [controlEditorValid, setControlEditorValid] = useState(false); + const [editorValid, setEditorValid] = useState(false); + + useEffect(() => setEditorValid(Boolean(currentTitle)), [currentTitle]); + + return ( + <> + <EuiFlyoutHeader hasBorder> + <EuiTitle size="m"> + <h2>{ControlGroupStrings.manageControl.getFlyoutTitle()}</h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <EuiForm> + <EuiFormRow label={ControlGroupStrings.manageControl.getTitleInputTitle()}> + <EuiFieldText + placeholder="Placeholder text" + value={currentTitle} + onChange={(e) => { + updateTitle(e.target.value); + setCurrentTitle(e.target.value); + }} + aria-label="Use aria labels when no actual label is in use" + /> + </EuiFormRow> + <EuiFormRow label={ControlGroupStrings.manageControl.getWidthInputTitle()}> + <EuiButtonGroup + color="primary" + legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()} + options={CONTROL_WIDTH_OPTIONS} + idSelected={currentWidth} + onChange={(newWidth: string) => { + setCurrentWidth(newWidth as ControlWidth); + updateWidth(newWidth as ControlWidth); + }} + /> + </EuiFormRow> + + <EuiSpacer size="l" /> + {controlEditorComponent && + controlEditorComponent({ setValidState: setControlEditorValid })} + <EuiSpacer size="l" /> + {removeControl && ( + <EuiButtonEmpty + aria-label={`delete-${title}`} + iconType="trash" + flush="left" + color="danger" + onClick={() => { + onCancel(); + removeControl(); + }} + > + {ControlGroupStrings.management.getDeleteButtonTitle()} + </EuiButtonEmpty> + )} + </EuiForm> + </EuiFlyoutBody> + <EuiFlyoutFooter> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + aria-label={`delete-${title}`} + iconType="cross" + onClick={() => { + onCancel(); + }} + > + {ControlGroupStrings.manageControl.getCancelTitle()} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + aria-label={`delete-${title}`} + iconType="check" + color="primary" + disabled={!editorValid || !controlEditorValid} + onClick={() => { + onSave(); + }} + > + {ControlGroupStrings.manageControl.getSaveChangesTitle()} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutFooter> + </> + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control_group_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control_group_component.tsx new file mode 100644 index 0000000000000..e766b16ade13a --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control_group_component.tsx @@ -0,0 +1,113 @@ +/* + * 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 useMount from 'react-use/lib/useMount'; +import React, { useState } from 'react'; +import { + EuiFlyoutHeader, + EuiButtonEmpty, + EuiButtonGroup, + EuiFlyoutBody, + EuiFormRow, + EuiSpacer, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; + +import { ControlsPanels } from '../types'; +import { ControlStyle, ControlWidth } from '../../types'; +import { ControlGroupStrings } from '../control_group_strings'; +import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from '../control_group_constants'; + +interface ManageControlGroupProps { + panels: ControlsPanels; + controlStyle: ControlStyle; + deleteAllEmbeddables: () => void; + setControlStyle: (style: ControlStyle) => void; + setAllPanelWidths: (newWidth: ControlWidth) => void; +} + +export const ManageControlGroup = ({ + panels, + controlStyle, + setControlStyle, + setAllPanelWidths, + deleteAllEmbeddables, +}: ManageControlGroupProps) => { + const [currentControlStyle, setCurrentControlStyle] = useState<ControlStyle>(controlStyle); + const [selectedWidth, setSelectedWidth] = useState<ControlWidth>(); + const [selectionDisplay, setSelectionDisplay] = useState(false); + + useMount(() => { + if (!panels || Object.keys(panels).length === 0) return; + const firstWidth = panels[Object.keys(panels)[0]].width; + if (Object.values(panels).every((panel) => panel.width === firstWidth)) { + setSelectedWidth(firstWidth); + } + }); + + return ( + <> + <EuiFlyoutHeader hasBorder> + <EuiTitle size="m"> + <h2>{ControlGroupStrings.management.getFlyoutTitle()}</h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <EuiFormRow label={ControlGroupStrings.management.getLayoutTitle()}> + <EuiButtonGroup + color="primary" + legend={ControlGroupStrings.management.controlStyle.getDesignSwitchLegend()} + options={CONTROL_LAYOUT_OPTIONS} + idSelected={currentControlStyle} + onChange={(newControlStyle) => { + setControlStyle(newControlStyle as ControlStyle); + setCurrentControlStyle(newControlStyle as ControlStyle); + }} + /> + </EuiFormRow> + <EuiSpacer size="m" /> + <EuiFormRow label={ControlGroupStrings.management.getWidthTitle()}> + <EuiSwitch + label={ControlGroupStrings.management.controlWidth.getChangeAllControlWidthsTitle()} + checked={selectionDisplay} + onChange={() => setSelectionDisplay(!selectionDisplay)} + /> + </EuiFormRow> + {selectionDisplay ? ( + <> + <EuiSpacer size="s" /> + <EuiButtonGroup + color="primary" + idSelected={selectedWidth ?? ''} + legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()} + options={CONTROL_WIDTH_OPTIONS} + onChange={(newWidth: string) => { + setAllPanelWidths(newWidth as ControlWidth); + setSelectedWidth(newWidth as ControlWidth); + }} + /> + </> + ) : undefined} + + <EuiSpacer size="xl" /> + + <EuiButtonEmpty + onClick={deleteAllEmbeddables} + aria-label={'delete-all'} + iconType="trash" + color="danger" + flush="left" + size="s" + > + {ControlGroupStrings.management.getDeleteAllButtonTitle()} + </EuiButtonEmpty> + </EuiFlyoutBody> + </> + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/types.ts b/src/plugins/presentation_util/public/components/controls/control_group/types.ts new file mode 100644 index 0000000000000..fb381610711e5 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/types.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. + */ + +import { PanelState, EmbeddableInput } from '../../../../../embeddable/public'; +import { ControlStyle, ControlWidth, InputControlInput } from '../types'; + +export interface ControlGroupInput + extends EmbeddableInput, + Omit<InputControlInput, 'twoLineLayout'> { + inheritParentState: { + useFilters: boolean; + useQuery: boolean; + useTimerange: boolean; + }; + controlStyle: ControlStyle; + panels: ControlsPanels; +} + +export interface ControlPanelState<TEmbeddableInput extends InputControlInput = InputControlInput> + extends PanelState<TEmbeddableInput> { + order: number; + width: ControlWidth; +} + +export interface ControlsPanels { + [panelId: string]: ControlPanelState; +} diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/index.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts similarity index 100% rename from src/plugins/presentation_util/public/components/input_controls/control_types/options_list/index.ts rename to src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list.scss b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss similarity index 100% rename from src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list.scss rename to src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx similarity index 97% rename from src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_component.tsx rename to src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx index 4aff1ff4eee96..0d12c69fdab46 100644 --- a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx @@ -15,7 +15,7 @@ import { OptionsListStrings } from './options_list_strings'; import { OptionsListPopover } from './options_list_popover_component'; import './options_list.scss'; -import { useStateObservable } from '../../use_state_observable'; +import { useStateObservable } from '../../hooks/use_state_observable'; export interface OptionsListComponentState { availableOptions?: EuiSelectableOption[]; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx new file mode 100644 index 0000000000000..3e5770da22ce9 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.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 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 { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import useMount from 'react-use/lib/useMount'; +import { ControlEditorProps, GetControlEditorComponentProps } from '../../types'; +import { + OptionsListEmbeddableInput, + OptionsListFieldFetcher, + OptionsListIndexPatternFetcher, +} from './options_list_embeddable'; +import { OptionsListStrings } from './options_list_strings'; + +interface OptionsListEditorProps extends ControlEditorProps { + onChange: GetControlEditorComponentProps<OptionsListEmbeddableInput>['onChange']; + fetchIndexPatterns: OptionsListIndexPatternFetcher; + initialInput?: Partial<OptionsListEmbeddableInput>; + fetchFields: OptionsListFieldFetcher; +} + +interface OptionsListEditorState { + availableIndexPatterns: Array<EuiSuperSelectOption<string>>; + indexPattern?: string; + availableFields: Array<EuiSuperSelectOption<string>>; + field?: string; +} + +export const OptionsListEditor = ({ + onChange, + fetchFields, + initialInput, + setValidState, + fetchIndexPatterns, +}: OptionsListEditorProps) => { + const [state, setState] = useState<OptionsListEditorState>({ + indexPattern: initialInput?.indexPattern, + field: initialInput?.field, + availableIndexPatterns: [], + availableFields: [], + }); + + const applySelection = ({ field, indexPattern }: { field?: string; indexPattern?: string }) => { + const newState = { ...(field ? { field } : {}), ...(indexPattern ? { indexPattern } : {}) }; + /** + * apply state and run onChange concurrently. State is copied here rather than by subscribing to embeddable + * input so that the same editor component can cover the 'create' use case. + */ + + setState((currentState) => { + return { ...currentState, ...newState }; + }); + onChange(newState); + }; + + useMount(() => { + (async () => { + const indexPatterns = (await fetchIndexPatterns()).map((indexPattern) => ({ + value: indexPattern, + inputDisplay: indexPattern, + })); + setState((currentState) => ({ ...currentState, availableIndexPatterns: indexPatterns })); + })(); + }); + + useEffect(() => { + (async () => { + let availableFields: Array<EuiSuperSelectOption<string>> = []; + if (state.indexPattern) { + availableFields = (await fetchFields(state.indexPattern)).map((field) => ({ + value: field, + inputDisplay: field, + })); + } + setState((currentState) => ({ ...currentState, availableFields })); + })(); + }, [state.indexPattern, fetchFields]); + + useEffect( + () => setValidState(Boolean(state.field) && Boolean(state.indexPattern)), + [state.field, setValidState, state.indexPattern] + ); + + return ( + <> + <EuiFormRow label={OptionsListStrings.editor.getIndexPatternTitle()}> + <EuiSuperSelect + options={state.availableIndexPatterns} + onChange={(indexPattern) => applySelection({ indexPattern })} + valueOfSelected={state.indexPattern} + /> + </EuiFormRow> + <EuiFormRow label={OptionsListStrings.editor.getFieldTitle()}> + <EuiSuperSelect + disabled={!state.indexPattern} + options={state.availableFields} + onChange={(field) => applySelection({ field })} + valueOfSelected={state.field} + /> + </EuiFormRow> + </> + ); +}; diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx similarity index 91% rename from src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable.tsx rename to src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx index bdd3660606b7e..93a7b3e353bdf 100644 --- a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx @@ -15,9 +15,9 @@ import { tap, debounceTime, map, distinctUntilChanged } from 'rxjs/operators'; import { esFilters } from '../../../../../../data/public'; import { OptionsListStrings } from './options_list_strings'; +import { Embeddable, IContainer } from '../../../../../../embeddable/public'; +import { InputControlInput, InputControlOutput } from '../../types'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; -import { Embeddable } from '../../../../../../embeddable/public'; -import { InputControlInput, InputControlOutput } from '../../embeddable/types'; const toggleAvailableOptions = ( indices: number[], @@ -50,6 +50,9 @@ interface OptionsListDataFetchProps { timeRange?: InputControlInput['timeRange']; } +export type OptionsListIndexPatternFetcher = () => Promise<string[]>; // TODO: use the proper types here. +export type OptionsListFieldFetcher = (indexPattern: string) => Promise<string[]>; // TODO: use the proper types here. + export type OptionsListDataFetcher = ( props: OptionsListDataFetchProps ) => Promise<EuiSelectableOption[]>; @@ -58,7 +61,7 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends InputControlInput { field: string; indexPattern: string; - multiSelect: boolean; + singleSelect?: boolean; defaultSelections?: string[]; } export class OptionsListEmbeddable extends Embeddable< @@ -66,14 +69,11 @@ export class OptionsListEmbeddable extends Embeddable< InputControlOutput > { public readonly type = OPTIONS_LIST_CONTROL; - private node?: HTMLElement; - private fetchData: OptionsListDataFetcher; // internal state for this input control. private selectedOptions: Set<string>; private typeaheadSubject: Subject<string> = new Subject<string>(); - private searchString: string = ''; private componentState: OptionsListComponentState; private componentStateSubject$ = new Subject<OptionsListComponentState>(); @@ -88,9 +88,10 @@ export class OptionsListEmbeddable extends Embeddable< constructor( input: OptionsListEmbeddableInput, output: InputControlOutput, - fetchData: OptionsListDataFetcher + private fetchData: OptionsListDataFetcher, + parent?: IContainer ) { - super(input, output); + super(input, output, parent); this.fetchData = fetchData; // populate default selections from input @@ -99,7 +100,7 @@ export class OptionsListEmbeddable extends Embeddable< // fetch available options when input changes or when search string has changed const typeaheadPipe = this.typeaheadSubject.pipe( - tap((newSearchString) => (this.searchString = newSearchString)), + tap((newSearchString) => this.updateComponentState({ searchString: newSearchString })), debounceTime(100) ); const inputPipe = this.getInput$().pipe( @@ -136,7 +137,7 @@ export class OptionsListEmbeddable extends Embeddable< const { indexPattern, timeRange, filters, field, query } = this.getInput(); let newOptions = await this.fetchData({ - search: this.searchString, + search: this.componentState.searchString, indexPattern, timeRange, filters, diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx new file mode 100644 index 0000000000000..01c31a0bcbc51 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx @@ -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 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 { EmbeddableFactoryDefinition, IContainer } from '../../../../../../embeddable/public'; +import { + ControlEditorProps, + GetControlEditorComponentProps, + IEditableControlFactory, +} from '../../types'; +import { OptionsListEditor } from './options_list_editor'; +import { + OptionsListDataFetcher, + OptionsListEmbeddable, + OptionsListEmbeddableInput, + OptionsListFieldFetcher, + OptionsListIndexPatternFetcher, + OPTIONS_LIST_CONTROL, +} from './options_list_embeddable'; + +export class OptionsListEmbeddableFactory + implements EmbeddableFactoryDefinition, IEditableControlFactory +{ + public type = OPTIONS_LIST_CONTROL; + + constructor( + private fetchData: OptionsListDataFetcher, + private fetchIndexPatterns: OptionsListIndexPatternFetcher, + private fetchFields: OptionsListFieldFetcher + ) { + this.fetchIndexPatterns = fetchIndexPatterns; + this.fetchFields = fetchFields; + this.fetchData = fetchData; + } + + public create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) { + return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, this.fetchData, parent)); + } + + public getControlEditor = ({ + onChange, + initialInput, + }: GetControlEditorComponentProps<OptionsListEmbeddableInput>) => { + return ({ setValidState }: ControlEditorProps) => ( + <OptionsListEditor + fetchIndexPatterns={this.fetchIndexPatterns} + fetchFields={this.fetchFields} + setValidState={setValidState} + initialInput={initialInput} + onChange={onChange} + /> + ); + }; + + public isEditable = () => Promise.resolve(false); + + public getDisplayName = () => 'Options List Control'; +} diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx similarity index 100% rename from src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_popover_component.tsx rename to src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_strings.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts similarity index 76% rename from src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_strings.ts rename to src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts index 2211ae14cb9bd..c07881020c9c2 100644 --- a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts @@ -19,6 +19,16 @@ export const OptionsListStrings = { defaultMessage: 'Select...', }), }, + editor: { + getIndexPatternTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.editor.indexPatternTitle', { + defaultMessage: 'Index pattern', + }), + getFieldTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.editor.fieldTitle', { + defaultMessage: 'Field', + }), + }, popover: { getLoadingMessage: () => i18n.translate('presentationUtil.inputControls.optionsList.popover.loading', { diff --git a/src/plugins/presentation_util/public/components/controls/controls_service.ts b/src/plugins/presentation_util/public/components/controls/controls_service.ts new file mode 100644 index 0000000000000..4e01f3cf9ab6a --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/controls_service.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 { EmbeddableFactory } from '../../../../embeddable/public'; +import { + ControlTypeRegistry, + InputControlEmbeddable, + InputControlFactory, + InputControlInput, + InputControlOutput, +} from './types'; + +export class ControlsService { + private controlsFactoriesMap: ControlTypeRegistry = {}; + + public registerInputControlType = (factory: InputControlFactory) => { + this.controlsFactoriesMap[factory.type] = factory; + }; + + public getControlFactory = < + I extends InputControlInput = InputControlInput, + O extends InputControlOutput = InputControlOutput, + E extends InputControlEmbeddable<I, O> = InputControlEmbeddable<I, O> + >( + type: string + ) => { + return this.controlsFactoriesMap[type] as EmbeddableFactory<I, O, E>; + }; + + public getInputControlTypes = () => Object.keys(this.controlsFactoriesMap); +} diff --git a/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts b/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts new file mode 100644 index 0000000000000..82b9aa528bf35 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { useEffect, useState } from 'react'; +import { InputControlEmbeddable } from '../types'; +import { IContainer } from '../../../../../embeddable/public'; + +export const useChildEmbeddable = ({ + container, + embeddableId, +}: { + container: IContainer; + embeddableId: string; +}) => { + const [embeddable, setEmbeddable] = useState<InputControlEmbeddable>(); + + useEffect(() => { + let mounted = true; + (async () => { + const newEmbeddable = await container.untilEmbeddableLoaded(embeddableId); + if (!mounted) return; + setEmbeddable(newEmbeddable); + })(); + return () => { + mounted = false; + }; + }, [container, embeddableId]); + + return embeddable; +}; diff --git a/src/plugins/presentation_util/public/components/input_controls/use_state_observable.ts b/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts similarity index 100% rename from src/plugins/presentation_util/public/components/input_controls/use_state_observable.ts rename to src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts diff --git a/src/plugins/presentation_util/public/components/input_controls/index.ts b/src/plugins/presentation_util/public/components/controls/index.ts similarity index 100% rename from src/plugins/presentation_util/public/components/input_controls/index.ts rename to src/plugins/presentation_util/public/components/controls/index.ts diff --git a/src/plugins/presentation_util/public/components/controls/types.ts b/src/plugins/presentation_util/public/components/controls/types.ts new file mode 100644 index 0000000000000..c94e2957e34ea --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/types.ts @@ -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 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 { Filter } from '@kbn/es-query'; +import { Query, TimeRange } from '../../../../data/public'; +import { + EmbeddableFactory, + EmbeddableInput, + EmbeddableOutput, + IEmbeddable, +} from '../../../../embeddable/public'; + +export type ControlWidth = 'auto' | 'small' | 'medium' | 'large'; +export type ControlStyle = 'twoLine' | 'oneLine'; + +/** + * Control embeddable types + */ +export type InputControlFactory = EmbeddableFactory< + InputControlInput, + InputControlOutput, + InputControlEmbeddable +>; + +export interface ControlTypeRegistry { + [key: string]: InputControlFactory; +} + +export type InputControlInput = EmbeddableInput & { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + twoLineLayout?: boolean; +}; + +export type InputControlOutput = EmbeddableOutput & { + filters?: Filter[]; +}; + +export type InputControlEmbeddable< + TInputControlEmbeddableInput extends InputControlInput = InputControlInput, + TInputControlEmbeddableOutput extends InputControlOutput = InputControlOutput +> = IEmbeddable<TInputControlEmbeddableInput, TInputControlEmbeddableOutput>; + +/** + * Control embeddable editor types + */ +export interface IEditableControlFactory<T extends InputControlInput = InputControlInput> { + getControlEditor?: GetControlEditorComponent<T>; +} + +export type GetControlEditorComponent<T extends InputControlInput = InputControlInput> = ( + props: GetControlEditorComponentProps<T> +) => ControlEditorComponent; +export interface GetControlEditorComponentProps<T extends InputControlInput = InputControlInput> { + onChange: (partial: Partial<T>) => void; + initialInput?: Partial<T>; +} + +export type ControlEditorComponent = (props: ControlEditorProps) => JSX.Element; + +export interface ControlEditorProps { + setValidState: (valid: boolean) => void; +} diff --git a/src/plugins/presentation_util/public/components/input_controls/__stories__/input_controls.stories.tsx b/src/plugins/presentation_util/public/components/input_controls/__stories__/input_controls.stories.tsx deleted file mode 100644 index d1ad3af0daf44..0000000000000 --- a/src/plugins/presentation_util/public/components/input_controls/__stories__/input_controls.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useEffect, useMemo, useState } from 'react'; - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; - -import { decorators } from './decorators'; -import { getEuiSelectableOptions, flightFields, flightFieldLabels, FlightField } from './flights'; -import { OptionsListEmbeddableFactory, OptionsListEmbeddable } from '../control_types/options_list'; -import { ControlFrame } from '../control_frame/control_frame'; - -export default { - title: 'Input Controls', - description: '', - decorators, -}; - -interface OptionsListStorybookArgs { - fields: string[]; - twoLine: boolean; -} - -const storybookArgs = { - twoLine: false, - fields: ['OriginCityName', 'OriginWeather', 'DestCityName', 'DestWeather'], -}; - -const storybookArgTypes = { - fields: { - twoLine: { - control: { type: 'bool' }, - }, - control: { - type: 'check', - options: flightFields, - }, - }, -}; - -const OptionsListStoryComponent = ({ fields, twoLine }: OptionsListStorybookArgs) => { - const [embeddables, setEmbeddables] = useState<OptionsListEmbeddable[]>([]); - - const optionsListEmbeddableFactory = useMemo( - () => - new OptionsListEmbeddableFactory( - ({ field, search }) => - new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500)) - ), - [] - ); - - useEffect(() => { - const embeddableCreatePromises = fields.map((field) => { - return optionsListEmbeddableFactory.create({ - field, - id: '', - indexPattern: '', - multiSelect: true, - twoLineLayout: twoLine, - title: flightFieldLabels[field as FlightField], - }); - }); - Promise.all(embeddableCreatePromises).then((newEmbeddables) => setEmbeddables(newEmbeddables)); - }, [fields, optionsListEmbeddableFactory, twoLine]); - - return ( - <EuiFlexGroup alignItems="center" wrap={true} gutterSize={'s'}> - {embeddables.map((embeddable) => ( - <EuiFlexItem key={embeddable.getInput().field}> - <ControlFrame twoLine={twoLine} embeddable={embeddable} /> - </EuiFlexItem> - ))} - </EuiFlexGroup> - ); -}; - -export const OptionsListStory = ({ fields, twoLine }: OptionsListStorybookArgs) => ( - <OptionsListStoryComponent fields={fields} twoLine={twoLine} /> -); - -OptionsListStory.args = storybookArgs; -OptionsListStory.argTypes = storybookArgTypes; diff --git a/src/plugins/presentation_util/public/components/input_controls/control_frame/control_frame.scss b/src/plugins/presentation_util/public/components/input_controls/control_frame/control_frame.scss deleted file mode 100644 index ad054be022c32..0000000000000 --- a/src/plugins/presentation_util/public/components/input_controls/control_frame/control_frame.scss +++ /dev/null @@ -1,14 +0,0 @@ -.controlFrame--formControlLayout { - width: 100%; - min-width: $euiSize * 12.5; -} - -.controlFrame--control { - &.optionsList--filterBtnSingle { - height: 100%; - } -} - -.optionsList--filterBtnTwoLine { - width: 100%; -} \ No newline at end of file diff --git a/src/plugins/presentation_util/public/components/input_controls/control_frame/control_frame.tsx b/src/plugins/presentation_util/public/components/input_controls/control_frame/control_frame.tsx deleted file mode 100644 index 7fa8688ffb368..0000000000000 --- a/src/plugins/presentation_util/public/components/input_controls/control_frame/control_frame.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useMemo } from 'react'; -import useMount from 'react-use/lib/useMount'; -import classNames from 'classnames'; -import { EuiFormControlLayout, EuiFormLabel, EuiFormRow } from '@elastic/eui'; - -import { InputControlEmbeddable } from '../embeddable/types'; - -import './control_frame.scss'; - -interface ControlFrameProps { - embeddable: InputControlEmbeddable; - twoLine?: boolean; -} - -export const ControlFrame = ({ twoLine, embeddable }: ControlFrameProps) => { - const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []); - - useMount(() => { - if (embeddableRoot.current && embeddable) embeddable.render(embeddableRoot.current); - }); - - const form = ( - <EuiFormControlLayout - className="controlFrame--formControlLayout" - fullWidth - prepend={ - twoLine ? undefined : ( - <EuiFormLabel htmlFor={embeddable.id}>{embeddable.getInput().title}</EuiFormLabel> - ) - } - > - <div - className={classNames('controlFrame--control', { - 'optionsList--filterBtnTwoLine': twoLine, - 'optionsList--filterBtnSingle': !twoLine, - })} - id={embeddable.id} - ref={embeddableRoot} - /> - </EuiFormControlLayout> - ); - - return twoLine ? ( - <EuiFormRow fullWidth label={embeddable.getInput().title}> - {form} - </EuiFormRow> - ) : ( - form - ); -}; diff --git a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable_factory.ts b/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable_factory.ts deleted file mode 100644 index e1850e6715e34..0000000000000 --- a/src/plugins/presentation_util/public/components/input_controls/control_types/options_list/options_list_embeddable_factory.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { EmbeddableFactoryDefinition } from '../../../../../../embeddable/public'; -import { - OptionsListDataFetcher, - OptionsListEmbeddable, - OptionsListEmbeddableInput, - OPTIONS_LIST_CONTROL, -} from './options_list_embeddable'; - -export class OptionsListEmbeddableFactory implements EmbeddableFactoryDefinition { - public type = OPTIONS_LIST_CONTROL; - private fetchData: OptionsListDataFetcher; - - constructor(fetchData: OptionsListDataFetcher) { - this.fetchData = fetchData; - } - - public create(initialInput: OptionsListEmbeddableInput) { - return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, this.fetchData)); - } - - public isEditable = () => Promise.resolve(false); - - public getDisplayName = () => 'Options List Control'; -} diff --git a/src/plugins/presentation_util/public/components/input_controls/embeddable/types.ts b/src/plugins/presentation_util/public/components/input_controls/embeddable/types.ts deleted file mode 100644 index 00be17932ba1f..0000000000000 --- a/src/plugins/presentation_util/public/components/input_controls/embeddable/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { Filter, Query, TimeRange } from '../../../../../data/public'; -import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../../../../../embeddable/public'; - -export type InputControlInput = EmbeddableInput & { - filters?: Filter[]; - query?: Query; - timeRange?: TimeRange; - twoLineLayout?: boolean; -}; - -export type InputControlOutput = EmbeddableOutput & { - filters?: Filter[]; -}; - -export type InputControlEmbeddable = IEmbeddable<InputControlInput, InputControlOutput>; diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts index d68779b129ca6..c622ad82bb888 100644 --- a/src/plugins/presentation_util/public/services/index.ts +++ b/src/plugins/presentation_util/public/services/index.ts @@ -12,6 +12,7 @@ import { PresentationCapabilitiesService } from './capabilities'; import { PresentationDashboardsService } from './dashboards'; import { PresentationLabsService } from './labs'; import { registry as stubRegistry } from './stub'; +import { PresentationOverlaysService } from './overlays'; export { PresentationCapabilitiesService } from './capabilities'; export { PresentationDashboardsService } from './dashboards'; @@ -19,6 +20,7 @@ export { PresentationLabsService } from './labs'; export interface PresentationUtilServices { dashboards: PresentationDashboardsService; capabilities: PresentationCapabilitiesService; + overlays: PresentationOverlaysService; labs: PresentationLabsService; } diff --git a/src/plugins/presentation_util/public/services/kibana/index.ts b/src/plugins/presentation_util/public/services/kibana/index.ts index 880f0f8b49c76..8a9a28606f24b 100644 --- a/src/plugins/presentation_util/public/services/kibana/index.ts +++ b/src/plugins/presentation_util/public/services/kibana/index.ts @@ -8,6 +8,7 @@ import { capabilitiesServiceFactory } from './capabilities'; import { dashboardsServiceFactory } from './dashboards'; +import { overlaysServiceFactory } from './overlays'; import { labsServiceFactory } from './labs'; import { PluginServiceProviders, @@ -20,6 +21,7 @@ import { PresentationUtilServices } from '..'; export { capabilitiesServiceFactory } from './capabilities'; export { dashboardsServiceFactory } from './dashboards'; +export { overlaysServiceFactory } from './overlays'; export { labsServiceFactory } from './labs'; export const providers: PluginServiceProviders< @@ -29,6 +31,7 @@ export const providers: PluginServiceProviders< capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + overlays: new PluginServiceProvider(overlaysServiceFactory), }; export const registry = new PluginServiceRegistry< diff --git a/src/plugins/presentation_util/public/services/kibana/overlays.ts b/src/plugins/presentation_util/public/services/kibana/overlays.ts new file mode 100644 index 0000000000000..b3a8d3a6e040a --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/overlays.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. + */ + +import { PresentationUtilPluginStartDeps } from '../../types'; +import { KibanaPluginServiceFactory } from '../create'; +import { PresentationOverlaysService } from '../overlays'; + +export type OverlaysServiceFactory = KibanaPluginServiceFactory< + PresentationOverlaysService, + PresentationUtilPluginStartDeps +>; +export const overlaysServiceFactory: OverlaysServiceFactory = ({ coreStart }) => { + const { + overlays: { openFlyout, openConfirm }, + } = coreStart; + + return { + openFlyout, + openConfirm, + }; +}; diff --git a/src/plugins/presentation_util/public/services/overlays.ts b/src/plugins/presentation_util/public/services/overlays.ts new file mode 100644 index 0000000000000..ee90de5231896 --- /dev/null +++ b/src/plugins/presentation_util/public/services/overlays.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 { + MountPoint, + OverlayFlyoutOpenOptions, + OverlayModalConfirmOptions, + OverlayRef, +} from '../../../../core/public'; + +export interface PresentationOverlaysService { + openFlyout(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef; + openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise<boolean>; +} diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts index 40fdc40a4632e..1ce1eb72848c9 100644 --- a/src/plugins/presentation_util/public/services/storybook/index.ts +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -11,6 +11,7 @@ import { dashboardsServiceFactory } from '../stub/dashboards'; import { labsServiceFactory } from './labs'; import { capabilitiesServiceFactory } from './capabilities'; import { PresentationUtilServices } from '..'; +import { overlaysServiceFactory } from './overlays'; export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; export { PresentationUtilServices } from '..'; @@ -25,6 +26,7 @@ export interface StorybookParams { export const providers: PluginServiceProviders<PresentationUtilServices, StorybookParams> = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + overlays: new PluginServiceProvider(overlaysServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), }; diff --git a/src/plugins/presentation_util/public/services/storybook/overlays.tsx b/src/plugins/presentation_util/public/services/storybook/overlays.tsx new file mode 100644 index 0000000000000..50194fb636fa4 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/overlays.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiConfirmModal, EuiFlyout } from '@elastic/eui'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Subject } from 'rxjs'; +import { + MountPoint, + OverlayFlyoutOpenOptions, + OverlayModalConfirmOptions, + OverlayRef, +} from '../../../../../core/public'; +import { MountWrapper } from '../../../../../core/public/utils'; +import { PluginServiceFactory } from '../create'; +import { PresentationOverlaysService } from '../overlays'; + +type OverlaysServiceFactory = PluginServiceFactory<PresentationOverlaysService>; + +/** + * This code is a storybook stub version of src/core/public/overlays/overlay_service.ts + * Eventually, core services should have simple storybook representations, but until that happens + * it is necessary to recreate their functionality here. + */ +class GenericOverlayRef implements OverlayRef { + public readonly onClose: Promise<void>; + private closeSubject = new Subject<void>(); + + constructor() { + this.onClose = this.closeSubject.toPromise(); + } + + public close(): Promise<void> { + if (!this.closeSubject.closed) { + this.closeSubject.next(); + this.closeSubject.complete(); + } + return this.onClose; + } +} + +export const overlaysServiceFactory: OverlaysServiceFactory = () => { + const flyoutDomElement = document.createElement('div'); + const modalDomElement = document.createElement('div'); + let activeFlyout: OverlayRef | null; + let activeModal: OverlayRef | null; + + const cleanupModal = () => { + if (modalDomElement != null) { + unmountComponentAtNode(modalDomElement); + modalDomElement.innerHTML = ''; + } + activeModal = null; + }; + + const cleanupFlyout = () => { + if (flyoutDomElement != null) { + unmountComponentAtNode(flyoutDomElement); + flyoutDomElement.innerHTML = ''; + } + activeFlyout = null; + }; + + return { + openFlyout: (mount: MountPoint, options?: OverlayFlyoutOpenOptions) => { + if (activeFlyout) { + activeFlyout.close(); + cleanupFlyout(); + } + + const flyout = new GenericOverlayRef(); + + flyout.onClose.then(() => { + if (activeFlyout === flyout) { + cleanupFlyout(); + } + }); + + activeFlyout = flyout; + + const onCloseFlyout = () => { + if (options?.onClose) { + options?.onClose(flyout); + return; + } + flyout.close(); + }; + + render( + <EuiFlyout onClose={onCloseFlyout}> + <MountWrapper mount={mount} className="kbnOverlayMountWrapper" /> + </EuiFlyout>, + flyoutDomElement + ); + + return flyout; + }, + openConfirm: (message: MountPoint | string, options?: OverlayModalConfirmOptions) => { + if (activeModal) { + activeModal.close(); + cleanupModal(); + } + + return new Promise((resolve, reject) => { + let resolved = false; + const closeModal = (confirmed: boolean) => { + resolved = true; + modal.close(); + resolve(confirmed); + }; + + const modal = new GenericOverlayRef(); + modal.onClose.then(() => { + if (activeModal === modal) { + cleanupModal(); + } + // modal.close can be called when opening a new modal/confirm, so we need to resolve the promise in that case. + if (!resolved) { + closeModal(false); + } + }); + activeModal = modal; + + const props = { + ...options, + children: + typeof message === 'string' ? ( + message + ) : ( + <MountWrapper mount={message} className="kbnOverlayMountWrapper" /> + ), + onCancel: () => closeModal(false), + onConfirm: () => closeModal(true), + cancelButtonText: options?.cancelButtonText || '', // stub default cancel text + confirmButtonText: options?.confirmButtonText || '', // stub default confirm text + }; + + render(<EuiConfirmModal {...props} />, modalDomElement); + }); + }, + }; +}; diff --git a/src/plugins/presentation_util/public/services/stub/index.ts b/src/plugins/presentation_util/public/services/stub/index.ts index 6bf32bba00a3e..61dca47427531 100644 --- a/src/plugins/presentation_util/public/services/stub/index.ts +++ b/src/plugins/presentation_util/public/services/stub/index.ts @@ -11,6 +11,7 @@ import { dashboardsServiceFactory } from './dashboards'; import { labsServiceFactory } from './labs'; import { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; import { PresentationUtilServices } from '..'; +import { overlaysServiceFactory } from './overlays'; export { dashboardsServiceFactory } from './dashboards'; export { capabilitiesServiceFactory } from './capabilities'; @@ -18,6 +19,7 @@ export { capabilitiesServiceFactory } from './capabilities'; export const providers: PluginServiceProviders<PresentationUtilServices> = { dashboards: new PluginServiceProvider(dashboardsServiceFactory), capabilities: new PluginServiceProvider(capabilitiesServiceFactory), + overlays: new PluginServiceProvider(overlaysServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), }; diff --git a/src/plugins/presentation_util/public/services/stub/overlays.ts b/src/plugins/presentation_util/public/services/stub/overlays.ts new file mode 100644 index 0000000000000..ecdec96d600d8 --- /dev/null +++ b/src/plugins/presentation_util/public/services/stub/overlays.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. + */ + +import { + MountPoint, + OverlayFlyoutOpenOptions, + OverlayModalConfirmOptions, + OverlayRef, +} from '../../../../../core/public'; +import { PluginServiceFactory } from '../create'; +import { PresentationOverlaysService } from '../overlays'; + +type OverlaysServiceFactory = PluginServiceFactory<PresentationOverlaysService>; + +class StubRef implements OverlayRef { + public readonly onClose: Promise<void> = Promise.resolve(); + + public close(): Promise<void> { + return this.onClose; + } +} + +export const overlaysServiceFactory: OverlaysServiceFactory = () => ({ + openFlyout: (mount: MountPoint, options?: OverlayFlyoutOpenOptions) => new StubRef(), + openConfirm: (message: MountPoint | string, options?: OverlayModalConfirmOptions) => + Promise.resolve(true), +}); diff --git a/src/plugins/telemetry/server/config/deprecations.test.ts b/src/plugins/telemetry/server/config/deprecations.test.ts index d4a8a1e2e148c..7807cd21916d5 100644 --- a/src/plugins/telemetry/server/config/deprecations.test.ts +++ b/src/plugins/telemetry/server/config/deprecations.test.ts @@ -6,12 +6,16 @@ * Side Public License, v 1. */ +import { configDeprecationsMock } from '../../../../core/server/mocks'; import { deprecateEndpointConfigs } from './deprecations'; import type { TelemetryConfigType } from './config'; import { TELEMETRY_ENDPOINT } from '../../common/constants'; + describe('deprecateEndpointConfigs', () => { const fromPath = 'telemetry'; const mockAddDeprecation = jest.fn(); + const deprecationContext = configDeprecationsMock.createContext(); + beforeEach(() => { jest.clearAllMocks(); }); @@ -28,7 +32,12 @@ describe('deprecateEndpointConfigs', () => { it('returns void if telemetry.* config is not set', () => { const rawConfig = createMockRawConfig(); - const result = deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation); + const result = deprecateEndpointConfigs( + rawConfig, + fromPath, + mockAddDeprecation, + deprecationContext + ); expect(result).toBe(undefined); }); @@ -36,7 +45,12 @@ describe('deprecateEndpointConfigs', () => { const rawConfig = createMockRawConfig({ url: TELEMETRY_ENDPOINT.MAIN_CHANNEL.STAGING, }); - const result = deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation); + const result = deprecateEndpointConfigs( + rawConfig, + fromPath, + mockAddDeprecation, + deprecationContext + ); expect(result).toMatchInlineSnapshot(` Object { "set": Array [ @@ -58,7 +72,12 @@ describe('deprecateEndpointConfigs', () => { const rawConfig = createMockRawConfig({ url: 'random-endpoint', }); - const result = deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation); + const result = deprecateEndpointConfigs( + rawConfig, + fromPath, + mockAddDeprecation, + deprecationContext + ); expect(result).toMatchInlineSnapshot(` Object { "set": Array [ @@ -80,7 +99,12 @@ describe('deprecateEndpointConfigs', () => { const rawConfig = createMockRawConfig({ optInStatusUrl: TELEMETRY_ENDPOINT.MAIN_CHANNEL.STAGING, }); - const result = deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation); + const result = deprecateEndpointConfigs( + rawConfig, + fromPath, + mockAddDeprecation, + deprecationContext + ); expect(result).toMatchInlineSnapshot(` Object { "set": Array [ @@ -102,7 +126,12 @@ describe('deprecateEndpointConfigs', () => { const rawConfig = createMockRawConfig({ optInStatusUrl: 'random-endpoint', }); - const result = deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation); + const result = deprecateEndpointConfigs( + rawConfig, + fromPath, + mockAddDeprecation, + deprecationContext + ); expect(result).toMatchInlineSnapshot(` Object { "set": Array [ @@ -124,7 +153,7 @@ describe('deprecateEndpointConfigs', () => { const rawConfig = createMockRawConfig({ url: TELEMETRY_ENDPOINT.MAIN_CHANNEL.PROD, }); - deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation); + deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation, deprecationContext); expect(mockAddDeprecation).toBeCalledTimes(1); expect(mockAddDeprecation.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -146,7 +175,7 @@ describe('deprecateEndpointConfigs', () => { const rawConfig = createMockRawConfig({ optInStatusUrl: 'random-endpoint', }); - deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation); + deprecateEndpointConfigs(rawConfig, fromPath, mockAddDeprecation, deprecationContext); expect(mockAddDeprecation).toBeCalledTimes(1); expect(mockAddDeprecation.mock.calls[0]).toMatchInlineSnapshot(` Array [ diff --git a/src/plugins/usage_collection/server/config.ts b/src/plugins/usage_collection/server/config.ts index faf8ce7535e8a..f09dc7d431b33 100644 --- a/src/plugins/usage_collection/server/config.ts +++ b/src/plugins/usage_collection/server/config.ts @@ -29,12 +29,6 @@ export type ConfigType = TypeOf<typeof configSchema>; export const config: PluginConfigDescriptor<ConfigType> = { schema: configSchema, - deprecations: ({ renameFromRoot }) => [ - renameFromRoot('ui_metric.enabled', 'usageCollection.uiCounters.enabled'), - renameFromRoot('ui_metric.debug', 'usageCollection.uiCounters.debug'), - renameFromRoot('usageCollection.uiMetric.enabled', 'usageCollection.uiCounters.enabled'), - renameFromRoot('usageCollection.uiMetric.debug', 'usageCollection.uiCounters.debug'), - ], exposeToBrowser: { uiCounters: true, }, diff --git a/src/plugins/vis_types/timeseries/public/application/components/_index.scss b/src/plugins/vis_types/timeseries/public/application/components/_index.scss index 4ee5c1863946b..e2718a9491aa6 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/_index.scss +++ b/src/plugins/vis_types/timeseries/public/application/components/_index.scss @@ -7,7 +7,6 @@ @import './series_editor'; @import './vis_editor'; @import './vis_editor_visualization'; -@import './vis_picker'; @import './vis_with_splits'; @import './aggs/index'; diff --git a/src/plugins/vis_types/timeseries/public/application/components/_vis_picker.scss b/src/plugins/vis_types/timeseries/public/application/components/_vis_picker.scss deleted file mode 100644 index 94f8d6fac97d4..0000000000000 --- a/src/plugins/vis_types/timeseries/public/application/components/_vis_picker.scss +++ /dev/null @@ -1,4 +0,0 @@ -.tvbVisPickerItem { - font-size: $euiFontSizeM; - font-weight: $euiFontWeightMedium; -} diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx index 5e66b50eac467..16eeaff30c208 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx @@ -57,7 +57,7 @@ export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverPro allowStringIndicesLabel = ( <FormattedMessage id="visTypeTimeseries.indexPatternSelect.switchModePopover.enableAllowStringIndices" - defaultMessage="To search by Elasticsearch indices enable {allowStringIndices} setting." + defaultMessage="To query Elasticsearch indices, you must enable the {allowStringIndices} setting." values={{ allowStringIndices: canEditAdvancedSettings ? ( <EuiLink color="accent" onClick={handleAllowStringIndicesLinkClick}> diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_picker.tsx b/src/plugins/vis_types/timeseries/public/application/components/vis_picker.tsx index 1e0ca864410c1..52038df5f4de7 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_picker.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_picker.tsx @@ -53,11 +53,10 @@ interface VisPickerProps { export const VisPicker = ({ onChange, currentVisType }: VisPickerProps) => { return ( - <EuiTabs> + <EuiTabs size="l"> {tabs.map(({ label, type }) => ( <EuiTab key={type} - className="tvbVisPickerItem" isSelected={type === currentVisType} onClick={() => onChange({ type })} data-test-subj={`${type}TsvbTypeBtn`} diff --git a/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/index.tsx b/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/index.tsx index c3eb659435b2d..dc7e13634d6d6 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/index.tsx +++ b/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/index.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { cloneDeep, get } from 'lodash'; import { EuiSpacer } from '@elastic/eui'; @@ -19,7 +19,6 @@ import { SeriesPanel } from './series_panel'; import { CategoryAxisPanel } from './category_axis_panel'; import { ValueAxesPanel } from './value_axes_panel'; import { - makeSerie, isAxisHorizontal, countNextAxisNumber, getUpdatedAxisName, @@ -27,6 +26,7 @@ import { mapPosition, mapPositionOpposingOpposite, } from './utils'; +import { getSeriesParams } from '../../../../utils/get_series_params'; export type SetParamByIndex = <P extends keyof ValueAxis, O extends keyof SeriesParam>( axesName: 'valueAxes' | 'seriesParams', @@ -273,40 +273,19 @@ function MetricsAxisOptions(props: ValidationVisOptionsProps<VisParams>) { ); const schemaName = vis.type.schemas.metrics[0].name; - const metrics = useMemo(() => { - return aggs.bySchemaName(schemaName); - }, [schemaName, aggs]); - const firstValueAxesId = stateParams.valueAxes[0].id; useEffect(() => { - const updatedSeries = metrics.map((agg) => { - const params = stateParams.seriesParams.find((param) => param.data.id === agg.id); - const label = agg.makeLabel(); - - // update labels for existing params or create new one - if (params) { - return { - ...params, - data: { - ...params.data, - label, - }, - }; - } else { - const series = makeSerie( - agg.id, - label, - firstValueAxesId, - stateParams.seriesParams[stateParams.seriesParams.length - 1] - ); - return series; - } - }); + const updatedSeries = getSeriesParams( + aggs, + stateParams.seriesParams, + schemaName, + firstValueAxesId + ); - setValue('seriesParams', updatedSeries); + if (updatedSeries) setValue('seriesParams', updatedSeries); updateAxisTitle(updatedSeries); - }, [metrics, firstValueAxesId, setValue, stateParams.seriesParams, updateAxisTitle]); + }, [firstValueAxesId, setValue, stateParams.seriesParams, updateAxisTitle, aggs, schemaName]); return isTabSelected ? ( <> diff --git a/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/utils.ts b/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/utils.ts index a8d53e45bc988..cd6368d961ec4 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/utils.ts +++ b/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/utils.ts @@ -10,30 +10,7 @@ import { upperFirst } from 'lodash'; import { Position } from '@elastic/charts'; -import { VisParams, ValueAxis, SeriesParam, ChartMode, InterpolationMode } from '../../../../types'; -import { ChartType } from '../../../../../common'; - -export const makeSerie = ( - id: string, - label: string, - defaultValueAxis: ValueAxis['id'], - lastSerie?: SeriesParam -): SeriesParam => { - const data = { id, label }; - const defaultSerie = { - show: true, - mode: ChartMode.Normal, - type: ChartType.Line, - drawLinesBetweenPoints: true, - showCircles: true, - circlesRadius: 3, - interpolate: InterpolationMode.Linear, - lineWidth: 2, - valueAxis: defaultValueAxis, - data, - }; - return lastSerie ? { ...lastSerie, data } : defaultSerie; -}; +import { VisParams, ValueAxis } from '../../../../types'; export const isAxisHorizontal = (position: Position) => [Position.Top, Position.Bottom].includes(position as any); diff --git a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts index 7fff29edfab51..de1ccdea1e79b 100644 --- a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts @@ -1780,6 +1780,28 @@ export const sampleAreaVis = { }, aggs: { typesRegistry: {}, + bySchemaName: () => [ + { + id: '1', + enabled: true, + type: 'sum', + params: { + field: 'total_quantity', + }, + schema: 'metric', + makeLabel: () => 'Total quantity', + toSerializedFieldFormat: () => ({ + id: 'number', + params: { + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + }, + }), + }, + ], getResponseAggs: () => [ { id: '1', diff --git a/src/plugins/vis_types/xy/public/to_ast.ts b/src/plugins/vis_types/xy/public/to_ast.ts index 5fc130a08ed27..9e2c7554aaf7c 100644 --- a/src/plugins/vis_types/xy/public/to_ast.ts +++ b/src/plugins/vis_types/xy/public/to_ast.ts @@ -33,6 +33,7 @@ import { visName, VisTypeXyExpressionFunctionDefinition } from './expression_fun import { XyVisType } from '../common'; import { getEsaggsFn } from './to_ast_esaggs'; import { TimeRangeBounds } from '../../../data/common'; +import { getSeriesParams } from './utils/get_series_params'; const prepareLabel = (data: Labels) => { const label = buildExpressionFunction('label', { @@ -145,6 +146,17 @@ export const toExpressionAst: VisToExpressionAst<VisParams> = async (vis, params const responseAggs = vis.data.aggs?.getResponseAggs().filter(({ enabled }) => enabled) ?? []; + const schemaName = vis.type.schemas?.metrics[0].name; + const firstValueAxesId = vis.params.valueAxes[0].id; + const updatedSeries = getSeriesParams( + vis.data.aggs, + vis.params.seriesParams, + schemaName, + firstValueAxesId + ); + + const finalSeriesParams = updatedSeries ?? vis.params.seriesParams; + if (dimensions.x) { const xAgg = responseAggs[dimensions.x.accessor] as any; if (xAgg.type.name === BUCKET_TYPES.DATE_HISTOGRAM) { @@ -202,7 +214,7 @@ export const toExpressionAst: VisToExpressionAst<VisParams> = async (vis, params orderBucketsBySum: vis.params.orderBucketsBySum, categoryAxes: vis.params.categoryAxes.map(prepareCategoryAxis), valueAxes: vis.params.valueAxes.map(prepareValueAxis), - seriesParams: vis.params.seriesParams.map(prepareSeriesParam), + seriesParams: finalSeriesParams.map(prepareSeriesParam), labels: prepareLabel(vis.params.labels), thresholdLine: prepareThresholdLine(vis.params.thresholdLine), gridCategoryLines: vis.params.grid.categoryLines, diff --git a/src/plugins/vis_types/xy/public/utils/get_series_params.test.ts b/src/plugins/vis_types/xy/public/utils/get_series_params.test.ts new file mode 100644 index 0000000000000..67b8a1c160d40 --- /dev/null +++ b/src/plugins/vis_types/xy/public/utils/get_series_params.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { AggConfigs } from '../../../../data/public'; +import type { SeriesParam } from '../types'; +import { getSeriesParams } from './get_series_params'; +import { sampleAreaVis } from '../sample_vis.test.mocks'; + +describe('getSeriesParams', () => { + it('returns correct params', () => { + const seriesParams = getSeriesParams( + sampleAreaVis.data.aggs as unknown as AggConfigs, + sampleAreaVis.params.seriesParams as unknown as SeriesParam[], + 'metric', + 'ValueAxis-1' + ); + expect(seriesParams).toStrictEqual([ + { + circlesRadius: 5, + data: { + id: '1', + label: 'Total quantity', + }, + drawLinesBetweenPoints: true, + interpolate: 'linear', + mode: 'stacked', + show: 'true', + showCircles: true, + type: 'area', + valueAxis: 'ValueAxis-1', + }, + ]); + }); + + it('returns default params if no params provided', () => { + const seriesParams = getSeriesParams( + sampleAreaVis.data.aggs as unknown as AggConfigs, + [], + 'metric', + 'ValueAxis-1' + ); + expect(seriesParams).toStrictEqual([ + { + circlesRadius: 3, + data: { + id: '1', + label: 'Total quantity', + }, + drawLinesBetweenPoints: true, + interpolate: 'linear', + lineWidth: 2, + mode: 'normal', + show: true, + showCircles: true, + type: 'line', + valueAxis: 'ValueAxis-1', + }, + ]); + }); +}); diff --git a/src/plugins/vis_types/xy/public/utils/get_series_params.ts b/src/plugins/vis_types/xy/public/utils/get_series_params.ts new file mode 100644 index 0000000000000..987c8df83b01f --- /dev/null +++ b/src/plugins/vis_types/xy/public/utils/get_series_params.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 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 { ValueAxis, SeriesParam, ChartMode, InterpolationMode } from '../types'; +import { ChartType } from '../../common'; +import type { AggConfigs } from '../../../../data/public'; + +const makeSerie = ( + id: string, + label: string, + defaultValueAxis: ValueAxis['id'], + lastSerie?: SeriesParam +): SeriesParam => { + const data = { id, label }; + const defaultSerie = { + show: true, + mode: ChartMode.Normal, + type: ChartType.Line, + drawLinesBetweenPoints: true, + showCircles: true, + circlesRadius: 3, + interpolate: InterpolationMode.Linear, + lineWidth: 2, + valueAxis: defaultValueAxis, + }; + return { ...defaultSerie, ...lastSerie, data }; +}; +export const getSeriesParams = ( + aggs: AggConfigs | undefined, + seriesParams: SeriesParam[], + schemaName: string, + firstValueAxesId: string +) => { + const metrics = aggs?.bySchemaName(schemaName); + + return metrics?.map((agg) => { + const params = seriesParams.find((param) => param.data.id === agg.id); + const label = agg.makeLabel(); + + // update labels for existing params or create new one + if (params) { + return { + ...params, + data: { + ...params.data, + label, + }, + }; + } else { + const series = makeSerie( + agg.id, + label, + firstValueAxesId, + seriesParams[seriesParams.length - 1] + ); + return series; + } + }); +}; diff --git a/src/plugins/vis_types/xy/public/vis_types/area.ts b/src/plugins/vis_types/xy/public/vis_types/area.ts index 6ba197ceb9424..3ff840f1817e9 100644 --- a/src/plugins/vis_types/xy/public/vis_types/area.ts +++ b/src/plugins/vis_types/xy/public/vis_types/area.ts @@ -78,7 +78,7 @@ export const areaVisTypeDefinition = { truncate: 100, }, title: { - text: defaultCountLabel, + text: '', }, style: {}, }, diff --git a/src/plugins/vis_types/xy/public/vis_types/histogram.ts b/src/plugins/vis_types/xy/public/vis_types/histogram.ts index bd549615fe7fd..dd65d6f31cb80 100644 --- a/src/plugins/vis_types/xy/public/vis_types/histogram.ts +++ b/src/plugins/vis_types/xy/public/vis_types/histogram.ts @@ -80,7 +80,7 @@ export const histogramVisTypeDefinition = { truncate: 100, }, title: { - text: defaultCountLabel, + text: '', }, style: {}, }, diff --git a/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts b/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts index 5bd45fc2eb7a8..c8494024d1d0a 100644 --- a/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts +++ b/src/plugins/vis_types/xy/public/vis_types/horizontal_bar.ts @@ -81,7 +81,7 @@ export const horizontalBarVisTypeDefinition = { truncate: 100, }, title: { - text: defaultCountLabel, + text: '', }, style: {}, }, diff --git a/src/plugins/vis_types/xy/public/vis_types/line.ts b/src/plugins/vis_types/xy/public/vis_types/line.ts index 747de1679c7c5..08e17f7e97d46 100644 --- a/src/plugins/vis_types/xy/public/vis_types/line.ts +++ b/src/plugins/vis_types/xy/public/vis_types/line.ts @@ -78,7 +78,7 @@ export const lineVisTypeDefinition = { truncate: 100, }, title: { - text: defaultCountLabel, + text: '', }, style: {}, }, diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index 4b1344ee8e84c..4b0745574b521 100644 --- a/test/api_integration/apis/custom_integration/integrations.ts +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -21,7 +21,9 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(resp.body).to.be.an('array'); - expect(resp.body.length).to.be.above(2); // Should at least have registered the three sample data-sets + + // sample data + expect(resp.body.length).to.be.above(13); // at least the language clients + tutorials + sample data ['flights', 'logs', 'ecommerce'].forEach((sampleData) => { expect(resp.body.findIndex((c: { id: string }) => c.id === sampleData)).to.be.above(-1); @@ -37,7 +39,8 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(resp.body).to.be.an('array'); - expect(resp.body.length).to.be.above(2); // Should have at least a few beats registered + + expect(resp.body.length).to.be.above(109); // at least the beats + apm }); }); }); diff --git a/test/common/config.js b/test/common/config.js index eb110fad55ea8..b9ab24450ac82 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -28,7 +28,6 @@ export default function () { buildArgs: [], sourceArgs: ['--no-base-path', '--env.name=development'], serverArgs: [ - '--logging.json=false', `--server.port=${kbnTestConfig.getPort()}`, '--status.allowAnonymous=true', // We shouldn't embed credentials into the URL since Kibana requests to Elasticsearch should diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.ts b/test/functional/apps/dashboard/create_and_add_embeddables.ts index 62ce68e026f72..6af295d2cf856 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.ts +++ b/test/functional/apps/dashboard/create_and_add_embeddables.ts @@ -68,6 +68,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.waitForRenderComplete(); }); + it('adds a new timelion visualization', async () => { + // adding this case, as the timelion agg-based viz doesn't need the `clickNewSearch()` step + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickTimelion(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'timelion visualization from add new link', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + it('adds a markdown visualization via the quick button', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await dashboardAddPanel.clickMarkdownQuickButton(); diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 4edc4d22f0753..0a8f56ee250ea 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -233,11 +233,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('time zone switch', () => { it('should show bars in the correct time zone after switching', async function () { - await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' }); + await kibanaServer.uiSettings.update({ 'dateFormat:tz': 'America/Phoenix' }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); - await queryBar.clearQuery(); await PageObjects.timePicker.setDefaultAbsoluteRange(); + await queryBar.clearQuery(); log.debug( 'check that the newest doc timestamp is now -7 hours from the UTC time in the first test' @@ -246,36 +246,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(rowData.startsWith('Sep 22, 2015 @ 16:50:13.253')).to.be.ok(); }); }); - describe('usage of discover:searchOnPageLoad', () => { - it('should not fetch data from ES initially when discover:searchOnPageLoad is false', async function () { - await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': false }); - await PageObjects.common.navigateToApp('discover'); - await PageObjects.header.awaitKibanaChrome(); - - expect(await PageObjects.discover.getNrOfFetches()).to.be(0); - }); - - it('should fetch data from ES initially when discover:searchOnPageLoad is true', async function () { - await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': true }); - await PageObjects.common.navigateToApp('discover'); - await PageObjects.header.awaitKibanaChrome(); - await retry.waitFor('number of fetches to be 1', async () => { - const nrOfFetches = await PageObjects.discover.getNrOfFetches(); - return nrOfFetches === 1; - }); - }); - }); describe('invalid time range in URL', function () { it('should get the default timerange', async function () { - const prevTime = await PageObjects.timePicker.getTimeConfig(); await PageObjects.common.navigateToUrl('discover', '#/?_g=(time:(from:now-15m,to:null))', { useActualUrl: true, }); await PageObjects.header.awaitKibanaChrome(); const time = await PageObjects.timePicker.getTimeConfig(); - expect(time.start).to.be(prevTime.start); - expect(time.end).to.be(prevTime.end); + expect(time.start).to.be('~ 15 minutes ago'); + expect(time.end).to.be('now'); }); }); diff --git a/test/functional/apps/discover/_errors.ts b/test/functional/apps/discover/_errors.ts index f127c39fd5d9a..b252cbf5f0824 100644 --- a/test/functional/apps/discover/_errors.ts +++ b/test/functional/apps/discover/_errors.ts @@ -12,7 +12,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const toasts = getService('toasts'); - const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'header', 'discover', 'timePicker']); describe('errors', function describeIndexTests() { before(async function () { @@ -33,5 +34,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(painlessStackTrace).not.to.be(undefined); }); }); + + describe('not found', () => { + it('should redirect to main page when trying to access invalid route', async () => { + await PageObjects.common.navigateToUrl('discover', '#/invalid-route', { + useActualUrl: true, + }); + await PageObjects.header.awaitKibanaChrome(); + + const invalidLink = await testSubjects.find('invalidRouteMessage'); + expect(await invalidLink.getVisibleText()).to.be( + `Discover application doesn't recognize this route: /invalid-route` + ); + }); + }); }); } diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 20f2cab907d9b..832d895fcea3d 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -17,39 +17,83 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); const browser = getService('browser'); - - const defaultSettings = { - defaultIndex: 'logstash-*', - }; const filterBar = getService('filterBar'); const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const testSubjects = getService('testSubjects'); + const defaultSettings = { + defaultIndex: 'logstash-*', + }; + + const setUpQueriesWithFilters = async () => { + // set up a query with filters and a time filter + log.debug('set up a query with filters to save'); + const fromTime = 'Sep 20, 2015 @ 08:00:00.000'; + const toTime = 'Sep 21, 2015 @ 08:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await filterBar.addFilter('extension.raw', 'is one of', 'jpg'); + await queryBar.setQuery('response:200'); + }; describe('saved queries saved objects', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); await kibanaServer.savedObjects.clean({ types: ['search', 'index-pattern'] }); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover.json'); + await esArchiver.load('test/functional/fixtures/es_archiver/date_nested'); + await esArchiver.load('test/functional/fixtures/es_archiver/logstash_functional'); - // and load a set of makelogs data - await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await kibanaServer.uiSettings.replace(defaultSettings); log.debug('discover'); await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('saved query management component functionality', function () { - before(async function () { - // set up a query with filters and a time filter - log.debug('set up a query with filters to save'); - await queryBar.setQuery('response:200'); - await filterBar.addFilter('extension.raw', 'is one of', 'jpg'); - const fromTime = 'Sep 20, 2015 @ 08:00:00.000'; - const toTime = 'Sep 21, 2015 @ 08:00:00.000'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await esArchiver.unload('test/functional/fixtures/es_archiver/date_nested'); + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + describe('saved query selection', () => { + before(async () => await setUpQueriesWithFilters()); + + it(`should unselect saved query when navigating to a 'new'`, async function () { + await savedQueryManagementComponent.saveNewQuery( + 'test-unselect-saved-query', + 'mock', + true, + true + ); + + await queryBar.submitQuery(); + + expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(true); + expect(await queryBar.getQueryString()).to.eql('response:200'); + + await PageObjects.discover.clickNewSearchButton(); + + expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(false); + expect(await queryBar.getQueryString()).to.eql(''); + + await PageObjects.discover.selectIndexPattern('date-nested'); + + expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(false); + expect(await queryBar.getQueryString()).to.eql(''); + + await PageObjects.discover.selectIndexPattern('logstash-*'); + + expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(false); + expect(await queryBar.getQueryString()).to.eql(''); + + // reset state + await savedQueryManagementComponent.deleteSavedQuery('test-unselect-saved-query'); }); + }); + + describe('saved query management component functionality', function () { + before(async () => await setUpQueriesWithFilters()); it('should show the saved query management component when there are no saved queries', async () => { await savedQueryManagementComponent.openSavedQueryManagementComponent(); diff --git a/test/functional/apps/discover/_search_on_page_load.ts b/test/functional/apps/discover/_search_on_page_load.ts new file mode 100644 index 0000000000000..2a66e03c3cbb8 --- /dev/null +++ b/test/functional/apps/discover/_search_on_page_load.ts @@ -0,0 +1,112 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const log = getService('log'); + const retry = getService('retry'); + const esArchiver = getService('esArchiver'); + const queryBar = getService('queryBar'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); + const testSubjects = getService('testSubjects'); + + const defaultSettings = { + defaultIndex: 'logstash-*', + }; + + const initSearchOnPageLoad = async (searchOnPageLoad: boolean) => { + await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': searchOnPageLoad }); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.awaitKibanaChrome(); + }; + + const waitForFetches = (fetchesNumber: number) => async () => { + const nrOfFetches = await PageObjects.discover.getNrOfFetches(); + return nrOfFetches === fetchesNumber; + }; + + describe('usage of discover:searchOnPageLoad', () => { + before(async function () { + log.debug('load kibana index with default index pattern'); + + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover.json'); + + // and load a set of data + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.load('test/functional/fixtures/es_archiver/date_nested'); + + await kibanaServer.uiSettings.replace(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + }); + + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await esArchiver.load('test/functional/fixtures/es_archiver/date_nested'); + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + describe(`when it's false`, () => { + beforeEach(async () => await initSearchOnPageLoad(false)); + + it('should not fetch data from ES initially', async function () { + expect(await testSubjects.exists('refreshDataButton')).to.be(true); + await retry.waitFor('number of fetches to be 0', waitForFetches(0)); + }); + + it('should not fetch on indexPattern change', async function () { + expect(await testSubjects.exists('refreshDataButton')).to.be(true); + await retry.waitFor('number of fetches to be 0', waitForFetches(0)); + + await PageObjects.discover.selectIndexPattern('date-nested'); + + expect(await testSubjects.exists('refreshDataButton')).to.be(true); + await retry.waitFor('number of fetches to be 0', waitForFetches(0)); + }); + + it('should fetch data from ES after refreshDataButton click', async function () { + expect(await testSubjects.exists('refreshDataButton')).to.be(true); + await retry.waitFor('number of fetches to be 0', waitForFetches(0)); + + await testSubjects.click('refreshDataButton'); + + await retry.waitFor('number of fetches to be 1', waitForFetches(1)); + expect(await testSubjects.exists('refreshDataButton')).to.be(false); + }); + + it('should fetch data from ES after submit query', async function () { + expect(await testSubjects.exists('refreshDataButton')).to.be(true); + await retry.waitFor('number of fetches to be 0', waitForFetches(0)); + + await queryBar.submitQuery(); + + await retry.waitFor('number of fetches to be 1', waitForFetches(1)); + expect(await testSubjects.exists('refreshDataButton')).to.be(false); + }); + + it('should fetch data from ES after choosing commonly used time range', async function () { + await PageObjects.discover.selectIndexPattern('logstash-*'); + expect(await testSubjects.exists('refreshDataButton')).to.be(true); + await retry.waitFor('number of fetches to be 0', waitForFetches(0)); + + await PageObjects.timePicker.setCommonlyUsedTime('This_week'); + + await retry.waitFor('number of fetches to be 1', waitForFetches(1)); + expect(await testSubjects.exists('refreshDataButton')).to.be(false); + }); + }); + + it(`when it's false should fetch data from ES initially`, async function () { + await initSearchOnPageLoad(true); + await retry.waitFor('number of fetches to be 1', waitForFetches(1)); + }); + }); +} diff --git a/test/functional/apps/discover/index.ts b/test/functional/apps/discover/index.ts index 3a18a55fe138b..59191b489f4c7 100644 --- a/test/functional/apps/discover/index.ts +++ b/test/functional/apps/discover/index.ts @@ -51,5 +51,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_runtime_fields_editor')); loadTestFile(require.resolve('./_huge_fields')); loadTestFile(require.resolve('./_date_nested')); + loadTestFile(require.resolve('./_search_on_page_load')); }); } diff --git a/test/scripts/jenkins_storybook.sh b/test/scripts/jenkins_storybook.sh index 17ca46b0097b1..bf8b881a91ecd 100755 --- a/test/scripts/jenkins_storybook.sh +++ b/test/scripts/jenkins_storybook.sh @@ -8,6 +8,7 @@ yarn storybook --site apm yarn storybook --site canvas yarn storybook --site codeeditor yarn storybook --site ci_composite +yarn storybook --site custom_integrations yarn storybook --site url_template_editor yarn storybook --site dashboard yarn storybook --site dashboard_enhanced diff --git a/test/server_integration/http/platform/config.status.ts b/test/server_integration/http/platform/config.status.ts index 8cc76c901f47c..20ffc917f8244 100644 --- a/test/server_integration/http/platform/config.status.ts +++ b/test/server_integration/http/platform/config.status.ts @@ -51,7 +51,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { runOptions: { ...httpConfig.get('kbnTestServer.runOptions'), // Don't wait for Kibana to be completely ready so that we can test the status timeouts - wait: /\[Kibana\]\[http\] http server running/, + wait: /Kibana is now unavailable/, }, }, }; diff --git a/test/server_integration/http/platform/status.ts b/test/server_integration/http/platform/status.ts index e443ce3f31cbf..c6ca805703bfd 100644 --- a/test/server_integration/http/platform/status.ts +++ b/test/server_integration/http/platform/status.ts @@ -23,6 +23,9 @@ export default function ({ getService }: FtrProviderContext) { return resp.body.status.plugins[pluginName]; }; + // max debounce of the status observable + 1 + const statusPropagation = () => new Promise((resolve) => setTimeout(resolve, 501)); + const setStatus = async <T extends keyof typeof ServiceStatusLevels>(level: T) => supertest .post(`/internal/status_plugin_a/status/set?level=${level}`) @@ -53,6 +56,7 @@ export default function ({ getService }: FtrProviderContext) { 5_000, async () => (await getStatus('statusPluginA')).level === 'degraded' ); + await statusPropagation(); expect((await getStatus('statusPluginA')).level).to.eql('degraded'); expect((await getStatus('statusPluginB')).level).to.eql('degraded'); @@ -62,6 +66,7 @@ export default function ({ getService }: FtrProviderContext) { 5_000, async () => (await getStatus('statusPluginA')).level === 'available' ); + await statusPropagation(); expect((await getStatus('statusPluginA')).level).to.eql('available'); expect((await getStatus('statusPluginB')).level).to.eql('available'); }); diff --git a/x-pack/examples/exploratory_view_example/.eslintrc.json b/x-pack/examples/exploratory_view_example/.eslintrc.json new file mode 100644 index 0000000000000..2aab6c2d9093b --- /dev/null +++ b/x-pack/examples/exploratory_view_example/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "@typescript-eslint/consistent-type-definitions": 0 + } +} diff --git a/x-pack/examples/exploratory_view_example/README.md b/x-pack/examples/exploratory_view_example/README.md new file mode 100644 index 0000000000000..dd2aaf18f32be --- /dev/null +++ b/x-pack/examples/exploratory_view_example/README.md @@ -0,0 +1,8 @@ +# Embedded Observability exploratory view example + +To run this example plugin, use the command `yarn start --run-examples`. + +This example shows how to embed Exploratory view into other observability solution applications. Using the exploratory view `EmbeddableComponent` of the `observability` start plugin, +you can pass in a valid Exploratory view series attributes which will get rendered the same way exploratory view works using Lens Embeddable. Updating the +configuration will reload the embedded visualization. + diff --git a/x-pack/examples/exploratory_view_example/kibana.json b/x-pack/examples/exploratory_view_example/kibana.json new file mode 100644 index 0000000000000..0ebc4bfe2e460 --- /dev/null +++ b/x-pack/examples/exploratory_view_example/kibana.json @@ -0,0 +1,20 @@ +{ + "id": "exploratoryViewExample", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["exploratory_view_example"], + "server": false, + "ui": true, + "requiredPlugins": [ + "observability", + "data", + "embeddable", + "developerExamples" + ], + "optionalPlugins": [], + "requiredBundles": [], + "owner": { + "name": "`Synthetics team`", + "githubTeam": "uptime" + } +} diff --git a/x-pack/examples/exploratory_view_example/package.json b/x-pack/examples/exploratory_view_example/package.json new file mode 100644 index 0000000000000..a4d42931554d0 --- /dev/null +++ b/x-pack/examples/exploratory_view_example/package.json @@ -0,0 +1,14 @@ +{ + "name": "exploratory_view_example", + "version": "1.0.0", + "main": "target/examples/exploratory_view_example", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "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/x-pack/examples/exploratory_view_example/public/app.tsx b/x-pack/examples/exploratory_view_example/public/app.tsx new file mode 100644 index 0000000000000..9ad37b6fdbfef --- /dev/null +++ b/x-pack/examples/exploratory_view_example/public/app.tsx @@ -0,0 +1,89 @@ +/* + * 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 { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; +import { IndexPattern } from 'src/plugins/data/public'; +import { CoreStart } from 'kibana/public'; +import { StartDependencies } from './plugin'; +import { AllSeries } from '../../../plugins/observability/public'; + +export const App = (props: { + core: CoreStart; + plugins: StartDependencies; + defaultIndexPattern: IndexPattern | null; +}) => { + const ExploratoryViewComponent = props.plugins.observability.ExploratoryViewEmbeddable; + + const seriesList: AllSeries = [ + { + name: 'Monitors response duration', + time: { + from: 'now-5d', + to: 'now', + }, + reportDefinitions: { + 'monitor.id': ['ALL_VALUES'], + }, + breakdown: 'observer.geo.name', + operationType: 'average', + dataType: 'synthetics', + seriesType: 'line', + selectedMetricField: 'monitor.duration.us', + }, + ]; + + const hrefLink = props.plugins.observability.createExploratoryViewUrl( + { reportType: 'kpi-over-time', allSeries: seriesList }, + props.core.http.basePath.get() + ); + + return ( + <EuiPage> + <EuiPageBody style={{ maxWidth: 1200, margin: '0 auto' }}> + <EuiPageHeader> + <EuiPageHeaderSection> + <EuiTitle size="l"> + <h1>Observability Exploratory View Example</h1> + </EuiTitle> + </EuiPageHeaderSection> + </EuiPageHeader> + <EuiPageContent> + <EuiPageContentBody style={{ maxWidth: 800, margin: '0 auto', height: '70vh' }}> + <p> + This app embeds an Observability Exploratory view as embeddable component. Make sure + you have data in heartbeat-* index within last 5 days for this demo to work. + </p> + <EuiFlexGroup justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <EuiButton aria-label="Open in exploratory view" href={hrefLink} target="_blank"> + Edit in exploratory view (new tab) + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + <ExploratoryViewComponent + attributes={seriesList} + reportType="kpi-over-time" + title={'Monitor response duration'} + /> + </EuiPageContentBody> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + ); +}; diff --git a/x-pack/examples/exploratory_view_example/public/index.ts b/x-pack/examples/exploratory_view_example/public/index.ts new file mode 100644 index 0000000000000..f8cb25977ae53 --- /dev/null +++ b/x-pack/examples/exploratory_view_example/public/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExploratoryViewExamplePlugin } from './plugin'; + +export const plugin = () => new ExploratoryViewExamplePlugin(); diff --git a/x-pack/examples/exploratory_view_example/public/mount.tsx b/x-pack/examples/exploratory_view_example/public/mount.tsx new file mode 100644 index 0000000000000..58ec363223270 --- /dev/null +++ b/x-pack/examples/exploratory_view_example/public/mount.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, AppMountParameters } from 'kibana/public'; +import { StartDependencies } from './plugin'; + +export const mount = + (coreSetup: CoreSetup<StartDependencies>) => + async ({ element }: AppMountParameters) => { + const [core, plugins] = await coreSetup.getStartServices(); + const { App } = await import('./app'); + + const deps = { + core, + plugins, + }; + + const defaultIndexPattern = await plugins.data.indexPatterns.getDefault(); + + const i18nCore = core.i18n; + + const reactElement = ( + <i18nCore.Context> + <App {...deps} defaultIndexPattern={defaultIndexPattern} /> + </i18nCore.Context> + ); + render(reactElement, element); + return () => unmountComponentAtNode(element); + }; diff --git a/x-pack/examples/exploratory_view_example/public/plugin.ts b/x-pack/examples/exploratory_view_example/public/plugin.ts new file mode 100644 index 0000000000000..50832d36c6fc9 --- /dev/null +++ b/x-pack/examples/exploratory_view_example/public/plugin.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Plugin, CoreSetup, AppNavLinkStatus } from '../../../../src/core/public'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { ObservabilityPublicStart } from '../../../plugins/observability/public'; +import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; +import { mount } from './mount'; + +export interface SetupDependencies { + developerExamples: DeveloperExamplesSetup; +} + +export interface StartDependencies { + data: DataPublicPluginStart; + observability: ObservabilityPublicStart; +} + +export class ExploratoryViewExamplePlugin + implements Plugin<void, void, SetupDependencies, StartDependencies> +{ + public setup(core: CoreSetup<StartDependencies>, { developerExamples }: SetupDependencies) { + core.application.register({ + id: 'exploratory_view_example', + title: 'Observability Exploratory View example', + navLinkStatus: AppNavLinkStatus.hidden, + mount: mount(core), + order: 1000, + }); + + developerExamples.register({ + appId: 'exploratory_view_example', + title: 'Observability Exploratory View', + description: + 'Embed Observability exploratory view in your observability solution app to render common visualizations', + }); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/examples/exploratory_view_example/tsconfig.json b/x-pack/examples/exploratory_view_example/tsconfig.json new file mode 100644 index 0000000000000..ef464f3815e28 --- /dev/null +++ b/x-pack/examples/exploratory_view_example/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types" + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../typings/**/*" + ], + "exclude": [], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../plugins/observability/tsconfig.json" }, + { "path": "../../../examples/developer_examples/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 7549d2ecaab77..ca51b1cdfea1b 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -439,7 +439,6 @@ describe('create()', () => { test('throws error creating action with disabled actionType', async () => { const localConfigUtils = getActionsConfigurationUtilities({ - enabled: true, enabledActionTypes: ['some-not-ignored-action-type'], allowedHosts: ['*'], preconfiguredAlertHistoryEsIndex: false, diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 51cd9e5599472..217f9593ee6d8 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -22,7 +22,6 @@ import moment from 'moment'; const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>; const defaultActionsConfig: ActionsConfig = { - enabled: false, allowedHosts: [], enabledActionTypes: [], preconfiguredAlertHistoryEsIndex: false, @@ -47,7 +46,6 @@ describe('ensureUriAllowed', () => { test('returns true when "any" hostnames are allowed', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], }; @@ -77,7 +75,6 @@ describe('ensureUriAllowed', () => { test('returns true when the hostname in the requested uri is in the allowedHosts', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], }; @@ -91,7 +88,6 @@ describe('ensureHostnameAllowed', () => { test('returns true when "any" hostnames are allowed', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], }; @@ -112,7 +108,6 @@ describe('ensureHostnameAllowed', () => { test('returns true when the hostname in the requested uri is in the allowedHosts', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], }; @@ -126,7 +121,6 @@ describe('isUriAllowed', () => { test('returns true when "any" hostnames are allowed', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], }; @@ -152,7 +146,6 @@ describe('isUriAllowed', () => { test('returns true when the hostname in the requested uri is in the allowedHosts', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], }; @@ -166,7 +159,6 @@ describe('isHostnameAllowed', () => { test('returns true when "any" hostnames are allowed', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], }; @@ -181,7 +173,6 @@ describe('isHostnameAllowed', () => { test('returns true when the hostname in the requested uri is in the allowedHosts', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], }; @@ -193,7 +184,6 @@ describe('isActionTypeEnabled', () => { test('returns true when "any" actionTypes are allowed', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', EnabledActionTypes.Any], }; @@ -203,7 +193,6 @@ describe('isActionTypeEnabled', () => { test('returns false when no actionType is allowed', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [], enabledActionTypes: [], }; @@ -213,7 +202,6 @@ describe('isActionTypeEnabled', () => { test('returns false when the actionType is not in the enabled list', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [], enabledActionTypes: ['foo'], }; @@ -223,7 +211,6 @@ describe('isActionTypeEnabled', () => { test('returns true when the actionType is in the enabled list', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', 'foo'], }; @@ -235,7 +222,6 @@ describe('ensureActionTypeEnabled', () => { test('does not throw when any actionType is allowed', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', EnabledActionTypes.Any], }; @@ -254,7 +240,6 @@ describe('ensureActionTypeEnabled', () => { test('throws when actionType is not enabled', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [], enabledActionTypes: ['ignore'], }; @@ -268,7 +253,6 @@ describe('ensureActionTypeEnabled', () => { test('does not throw when actionType is enabled', () => { const config: ActionsConfig = { ...defaultActionsConfig, - enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', 'foo'], }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts index 4ed9485e923a7..149ac79522f73 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -247,7 +247,6 @@ async function createServer(useHttps: boolean = false): Promise<CreateServerResu } const BaseActionsConfig: ActionsConfig = { - enabled: true, allowedHosts: ['*'], enabledActionTypes: ['*'], preconfiguredAlertHistoryEsIndex: false, diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index d99b9349e977b..7f3ce5bee6e02 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -29,7 +29,6 @@ describe('config validation', () => { "idleInterval": "PT1H", "pageSize": 100, }, - "enabled": true, "enabledActionTypes": Array [ "*", ], @@ -70,7 +69,6 @@ describe('config validation', () => { "idleInterval": "PT1H", "pageSize": 100, }, - "enabled": true, "enabledActionTypes": Array [ "*", ], @@ -196,7 +194,6 @@ describe('config validation', () => { "idleInterval": "PT1H", "pageSize": 100, }, - "enabled": true, "enabledActionTypes": Array [ "*", ], diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 54fd0d72bccee..cf05ee9a24eec 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -57,7 +57,6 @@ const customHostSettingsSchema = schema.object({ export type CustomHostSettings = TypeOf<typeof customHostSettingsSchema>; export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), allowedHosts: schema.arrayOf( schema.oneOf([schema.string({ hostname: true }), schema.literal(AllowedHosts.Any)]), { diff --git a/x-pack/plugins/actions/server/index.test.ts b/x-pack/plugins/actions/server/index.test.ts deleted file mode 100644 index dbe8fca806f17..0000000000000 --- a/x-pack/plugins/actions/server/index.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { config } from './index'; -import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; - -const CONFIG_PATH = 'xpack.actions'; -const applyStackAlertDeprecations = (settings: Record<string, unknown> = {}) => { - const deprecations = config.deprecations!(configDeprecationFactory); - const deprecationMessages: string[] = []; - const _config = { - [CONFIG_PATH]: settings, - }; - const { config: migrated } = applyDeprecations( - _config, - deprecations.map((deprecation) => ({ - deprecation, - path: CONFIG_PATH, - })), - () => - ({ message }) => - deprecationMessages.push(message) - ); - return { - messages: deprecationMessages, - migrated, - }; -}; - -describe('index', () => { - describe('deprecations', () => { - it('should deprecate .enabled flag', () => { - const { messages } = applyStackAlertDeprecations({ enabled: false }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"xpack.actions.enabled\\" is deprecated. The ability to disable this plugin will be removed in 8.0.0.", - ] - `); - }); - }); -}); diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index bf59a1a11687d..bfddd22003978 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -122,16 +122,5 @@ export const config: PluginConfigDescriptor<ActionsConfig> = { }); } }, - (settings, fromPath, addDeprecation) => { - const actions = get(settings, fromPath); - if (actions?.enabled === false || actions?.enabled === true) { - addDeprecation({ - message: `"xpack.actions.enabled" is deprecated. The ability to disable this plugin will be removed in 8.0.0.`, - correctiveActions: { - manualSteps: [`Remove "xpack.actions.enabled" from your kibana configs.`], - }, - }); - } - }, ], }; diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts index ec7b46e545112..48c9352566118 100644 --- a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -65,7 +65,6 @@ describe('custom_host_settings', () => { describe('resolveCustomHosts()', () => { const defaultActionsConfig: ActionsConfig = { - enabled: true, allowedHosts: [], enabledActionTypes: [], preconfiguredAlertHistoryEsIndex: false, diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 86d2de783ebe5..08ea99df67c8e 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -34,7 +34,6 @@ describe('Actions Plugin', () => { beforeEach(() => { context = coreMock.createPluginInitializerContext<ActionsConfig>({ - enabled: true, enabledActionTypes: ['*'], allowedHosts: ['*'], preconfiguredAlertHistoryEsIndex: false, @@ -253,7 +252,6 @@ describe('Actions Plugin', () => { beforeEach(() => { context = coreMock.createPluginInitializerContext<ActionsConfig>({ - enabled: true, enabledActionTypes: ['*'], allowedHosts: ['*'], preconfiguredAlertHistoryEsIndex: false, diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 94cdeadee97e7..58d2ca35dea7e 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -118,6 +118,7 @@ The following table describes the properties of the `options` object. |executor|This is where the code for the rule type lives. This is a function to be called when executing a rule on an interval basis. For full details, see the executor section below.|Function| |producer|The id of the application producing this rule type.|string| |minimumLicenseRequired|The value of a minimum license. Most of the rules are licensed as "basic".|string| +|ruleTaskTimeout|The length of time a rule can run before being cancelled due to timeout. By default, this value is "5m".|string| |useSavedObjectReferences.extractReferences|(Optional) When developing a rule type, you can choose to implement hooks for extracting saved object references from rule parameters. This hook will be invoked when a rule is created or updated. Implementing this hook is optional, but if an extract hook is implemented, an inject hook must also be implemented.|Function |useSavedObjectReferences.injectReferences|(Optional) When developing a rule type, you can choose to implement hooks for injecting saved object references into rule parameters. This hook will be invoked when a rule is retrieved (get or find). Implementing this hook is optional, but if an inject hook is implemented, an extract hook must also be implemented.|Function |isExportable|Whether the rule type is exportable from the Saved Objects Management UI.|boolean| @@ -344,6 +345,7 @@ const myRuleType: AlertType< }; }, producer: 'alerting', + ruleTaskTimeout: '10m', useSavedObjectReferences: { extractReferences: (params: Params): RuleParamsAndRefs<ExtractedParams> => { const { testSavedObjectId, ...otherParams } = params; diff --git a/x-pack/plugins/alerting/server/config.test.ts b/x-pack/plugins/alerting/server/config.test.ts index a1ae77596ccbe..63d93b9d67769 100644 --- a/x-pack/plugins/alerting/server/config.test.ts +++ b/x-pack/plugins/alerting/server/config.test.ts @@ -12,6 +12,7 @@ describe('config validation', () => { const config: Record<string, unknown> = {}; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { + "defaultRuleTaskTimeout": "5m", "healthCheck": Object { "interval": "60m", }, diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index 47ef451ceab92..277f0c7297df9 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -20,6 +20,7 @@ export const configSchema = schema.object({ maxEphemeralActionsPerAlert: schema.number({ defaultValue: DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT, }), + defaultRuleTaskTimeout: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), }); export type AlertsConfig = TypeOf<typeof configSchema>; diff --git a/x-pack/plugins/alerting/server/health/get_state.test.ts b/x-pack/plugins/alerting/server/health/get_state.test.ts index 9429dcc07d927..f4306b8250b81 100644 --- a/x-pack/plugins/alerting/server/health/get_state.test.ts +++ b/x-pack/plugins/alerting/server/health/get_state.test.ts @@ -72,6 +72,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '20m', }), pollInterval ).subscribe(); @@ -106,6 +107,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '20m', }), pollInterval, retryDelay @@ -151,6 +153,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '20m', }) ).toPromise(); @@ -182,6 +185,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '20m', }) ).toPromise(); @@ -213,6 +217,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '20m', }) ).toPromise(); @@ -241,6 +246,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '20m', }), retryDelay ).subscribe((status) => { @@ -272,6 +278,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '20m', }), retryDelay ).subscribe((status) => { @@ -309,6 +316,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '20m', }) ).toPromise(); diff --git a/x-pack/plugins/alerting/server/index.test.ts b/x-pack/plugins/alerting/server/index.test.ts deleted file mode 100644 index b1e64935d7cd9..0000000000000 --- a/x-pack/plugins/alerting/server/index.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { config } from './index'; -import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; - -const CONFIG_PATH = 'xpack.alerting'; -const applyStackAlertDeprecations = (settings: Record<string, unknown> = {}) => { - const deprecations = config.deprecations!(configDeprecationFactory); - const deprecationMessages: string[] = []; - const _config = { - [CONFIG_PATH]: settings, - }; - const { config: migrated } = applyDeprecations( - _config, - deprecations.map((deprecation) => ({ - deprecation, - path: CONFIG_PATH, - })), - () => - ({ message }) => - deprecationMessages.push(message) - ); - return { - messages: deprecationMessages, - migrated, - }; -}; - -describe('index', () => { - describe('deprecations', () => { - it('should deprecate .enabled flag', () => { - const { messages } = applyStackAlertDeprecations({ enabled: false }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"xpack.alerting.enabled\\" is deprecated. The ability to disable this plugin will be removed in 8.0.0.", - ] - `); - }); - }); -}); diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 3b4688173e9b5..162ee06216304 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { get } from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { RulesClient as RulesClientClass } from './rules_client'; import { PluginConfigDescriptor, PluginInitializerContext } from '../../../../src/core/server'; @@ -58,16 +57,5 @@ export const config: PluginConfigDescriptor<AlertsConfigType> = { 'xpack.alerts.invalidateApiKeysTask.removalDelay', 'xpack.alerting.invalidateApiKeysTask.removalDelay' ), - (settings, fromPath, addDeprecation) => { - const alerting = get(settings, fromPath); - if (alerting?.enabled === false || alerting?.enabled === true) { - addDeprecation({ - message: `"xpack.alerting.enabled" is deprecated. The ability to disable this plugin will be removed in 8.0.0.`, - correctiveActions: { - manualSteps: [`Remove "xpack.alerting.enabled" from your kibana configs.`], - }, - }); - } - }, ], }; diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 4cfa1d91758ea..6419a3ccc5c90 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -38,6 +38,7 @@ describe('Alerting Plugin', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 10, + defaultRuleTaskTimeout: '5m', }); plugin = new AlertingPlugin(context); @@ -71,6 +72,7 @@ describe('Alerting Plugin', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 10, + defaultRuleTaskTimeout: '5m', }); plugin = new AlertingPlugin(context); @@ -142,6 +144,15 @@ describe('Alerting Plugin', () => { minimumLicenseRequired: 'basic', }); }); + + it('should apply default config value for ruleTaskTimeout', async () => { + const ruleType = { + ...sampleAlertType, + minimumLicenseRequired: 'basic', + } as AlertType<never, never, never, never, never, 'default', never>; + await setup.registerType(ruleType); + expect(ruleType.ruleTaskTimeout).toBe('5m'); + }); }); }); @@ -157,6 +168,7 @@ describe('Alerting Plugin', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 10, + defaultRuleTaskTimeout: '5m', }); const plugin = new AlertingPlugin(context); @@ -197,6 +209,7 @@ describe('Alerting Plugin', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 10, + defaultRuleTaskTimeout: '5m', }); const plugin = new AlertingPlugin(context); @@ -251,6 +264,7 @@ describe('Alerting Plugin', () => { removalDelay: '1h', }, maxEphemeralActionsPerAlert: 100, + defaultRuleTaskTimeout: '5m', }); const plugin = new AlertingPlugin(context); diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index bb42beba6e237..b63fa94fbad72 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -285,6 +285,7 @@ export class AlertingPlugin { encryptedSavedObjects: plugins.encryptedSavedObjects, }); + const alertingConfig = this.config; return { registerType< Params extends AlertTypeParams = AlertTypeParams, @@ -308,7 +309,14 @@ export class AlertingPlugin { if (!(alertType.minimumLicenseRequired in LICENSE_TYPE)) { throw new Error(`"${alertType.minimumLicenseRequired}" is not a valid license type`); } - ruleTypeRegistry.register(alertType); + if (!alertType.ruleTaskTimeout) { + alertingConfig.then((config) => { + alertType.ruleTaskTimeout = config.defaultRuleTaskTimeout; + ruleTypeRegistry.register(alertType); + }); + } else { + ruleTypeRegistry.register(alertType); + } }, }; } diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts index f8067a2281f65..1c44e862c261c 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -112,6 +112,32 @@ describe('register()', () => { ); }); + test('throws if AlertType ruleTaskTimeout is not a valid duration', () => { + const alertType: AlertType<never, never, never, never, never, 'default'> = { + id: 123 as unknown as string, + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + ruleTaskTimeout: '23 milisec', + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + + expect(() => registry.register(alertType)).toThrowError( + new Error( + `Rule type \"123\" has invalid timeout: string is not a valid duration: 23 milisec.` + ) + ); + }); + test('throws if RuleType action groups contains reserved group id', () => { const alertType: AlertType<never, never, never, never, never, 'default' | 'NotReserved'> = { id: 'test', @@ -181,6 +207,28 @@ describe('register()', () => { `); }); + test('allows an AlertType to specify a custom rule task timeout', () => { + const alertType: AlertType<never, never, never, never, never, 'default', 'backToAwesome'> = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + ruleTaskTimeout: '13m', + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'basic', + isExportable: true, + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register(alertType); + expect(registry.get('test').ruleTaskTimeout).toBe('13m'); + }); + test('throws if the custom recovery group is contained in the AlertType action groups', () => { const alertType: AlertType< never, @@ -237,6 +285,7 @@ describe('register()', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', + ruleTaskTimeout: '20m', }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(alertType); @@ -246,6 +295,7 @@ describe('register()', () => { Object { "alerting:test": Object { "createTaskRunner": [Function], + "timeout": "20m", "title": "Test", }, }, diff --git a/x-pack/plugins/alerting/server/rule_type_registry.ts b/x-pack/plugins/alerting/server/rule_type_registry.ts index 3cd21d0c64dd5..dc72b644b2c7b 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.ts @@ -25,6 +25,7 @@ import { getBuiltinActionGroups, RecoveredActionGroupId, ActionGroup, + validateDurationSchema, } from '../common'; import { ILicenseState } from './lib/license_state'; import { getAlertTypeFeatureUsageName } from './lib/get_alert_type_feature_usage_name'; @@ -170,6 +171,21 @@ export class RuleTypeRegistry { }) ); } + // validate ruleTypeTimeout here + if (alertType.ruleTaskTimeout) { + const invalidTimeout = validateDurationSchema(alertType.ruleTaskTimeout); + if (invalidTimeout) { + throw new Error( + i18n.translate('xpack.alerting.ruleTypeRegistry.register.invalidTimeoutAlertTypeError', { + defaultMessage: 'Rule type "{id}" has invalid timeout: {errorMessage}.', + values: { + id: alertType.id, + errorMessage: invalidTimeout, + }, + }) + ); + } + } alertType.actionVariables = normalizedActionVariables(alertType.actionVariables); const normalizedAlertType = augmentActionGroupsWithReserved< @@ -190,6 +206,7 @@ export class RuleTypeRegistry { this.taskManager.registerTaskDefinitions({ [`alerting:${alertType.id}`]: { title: alertType.name, + timeout: alertType.ruleTaskTimeout, createTaskRunner: (context: RunContext) => this.taskRunnerFactory.create< Params, diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index ba35890efd781..c73ce86acf785 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -157,8 +157,8 @@ export interface AlertType< injectReferences: (params: ExtractedParams, references: SavedObjectReference[]) => Params; }; isExportable: boolean; + ruleTaskTimeout?: string; } - export type UntypedAlertType = AlertType< AlertTypeParams, AlertTypeState, diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index c4658ae2ac22c..2d1433324858b 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -53,6 +53,8 @@ exports[`Error ERROR_EXC_TYPE 1`] = `undefined`; exports[`Error ERROR_GROUP_ID 1`] = `"grouping key"`; +exports[`Error ERROR_ID 1`] = `"error id"`; + exports[`Error ERROR_LOG_LEVEL 1`] = `undefined`; exports[`Error ERROR_LOG_MESSAGE 1`] = `undefined`; @@ -298,6 +300,8 @@ exports[`Span ERROR_EXC_TYPE 1`] = `undefined`; exports[`Span ERROR_GROUP_ID 1`] = `undefined`; +exports[`Span ERROR_ID 1`] = `undefined`; + exports[`Span ERROR_LOG_LEVEL 1`] = `undefined`; exports[`Span ERROR_LOG_MESSAGE 1`] = `undefined`; @@ -535,6 +539,8 @@ exports[`Transaction ERROR_EXC_TYPE 1`] = `undefined`; exports[`Transaction ERROR_GROUP_ID 1`] = `undefined`; +exports[`Transaction ERROR_ID 1`] = `undefined`; + exports[`Transaction ERROR_LOG_LEVEL 1`] = `undefined`; exports[`Transaction ERROR_LOG_MESSAGE 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index d1f07c28bc808..4a4cad5454c4b 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -78,6 +78,7 @@ export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM = // Parent ID for a transaction or span export const PARENT_ID = 'parent.id'; +export const ERROR_ID = 'error.id'; export const ERROR_GROUP_ID = 'error.grouping_key'; export const ERROR_CULPRIT = 'error.culprit'; export const ERROR_LOG_LEVEL = 'error.log.level'; diff --git a/x-pack/plugins/apm/common/processor_event.ts b/x-pack/plugins/apm/common/processor_event.ts index 57705e7ed4ce0..fe0d9abfa0e51 100644 --- a/x-pack/plugins/apm/common/processor_event.ts +++ b/x-pack/plugins/apm/common/processor_event.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import * as t from 'io-ts'; export enum ProcessorEvent { transaction = 'transaction', @@ -12,6 +13,14 @@ export enum ProcessorEvent { span = 'span', profile = 'profile', } + +export const processorEventRt = t.union([ + t.literal(ProcessorEvent.transaction), + t.literal(ProcessorEvent.error), + t.literal(ProcessorEvent.metric), + t.literal(ProcessorEvent.span), + t.literal(ProcessorEvent.profile), +]); /** * Processor events that are searchable in the UI via the query bar. * diff --git a/x-pack/plugins/apm/dev_docs/apm_queries.md b/x-pack/plugins/apm/dev_docs/apm_queries.md new file mode 100644 index 0000000000000..7d730d2ef2a77 --- /dev/null +++ b/x-pack/plugins/apm/dev_docs/apm_queries.md @@ -0,0 +1,426 @@ +# Transactions + +Transactions are stored in two different formats: + +#### Individual transactions document + +A single transaction with a latency of 2ms + +```json +{ + "@timestamp": "2021-09-01T10:00:00.000Z", + "processor.event": "transaction", + "transaction.duration.us": 2000, + "event.outcome": "success" +} +``` + +or + +#### Aggregated (metric) document +A pre-aggregated document where `_doc_count` is the number of original transactions, and `transaction.duration.histogram` is the latency distribution. + +```json +{ + "_doc_count": 2, + "@timestamp": "2021-09-01T10:00:00.000Z", + "processor.event": "metric", + "metricset.name": "transaction", + "transaction.duration.histogram": { + "counts": [1, 1], + "values": [2000, 3000] + }, + "event.outcome": "success" +} +``` + +The decision to use aggregated transactions or not is determined in [`getSearchAggregatedTransactions`](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts#L53-L79) and then used to [specify the index](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/suggestions/get_suggestions.ts#L30-L32) and the [latency field](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts#L62-L65) + +### Latency + +Latency is the duration of a transaction. This can be calculated using transaction events or metric events (aggregated transactions). + +Noteworthy fields: `transaction.duration.us`, `transaction.duration.histogram` + +#### Transaction-based latency + +```json +{ + "size": 0, + "query": { + "bool": { + "filter": [{ "terms": { "processor.event": ["transaction"] } }] + } + }, + "aggs": { + "latency": { "avg": { "field": "transaction.duration.us" } } + } +} +``` + +#### Metric-based latency + +```json +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "term": { "metricset.name": "transaction" } } + ] + } + }, + "aggs": { + "latency": { "avg": { "field": "transaction.duration.histogram" } } + } +} +``` + +Please note: `metricset.name: transaction` was only recently introduced. To retain backwards compatability we still use the old filter `{ "exists": { "field": "transaction.duration.histogram" }}` when filtering for aggregated transactions. + +### Throughput + +Throughput is the number of transactions per minute. This can be calculated using transaction events or metric events (aggregated transactions). + +Noteworthy fields: None (based on `doc_count`) + +```js +{ + "size": 0, + "query": { + // same filters as for latency + }, + "aggs": { + "throughput": { "rate": { "unit": "minute" } } + } +} +``` + +### Failed transaction rate + +Failed transaction rate is the number of transactions with `event.outcome=failure` per minute. +Noteworthy fields: `event.outcome` + +```js +{ + "size": 0, + "query": { + // same filters as for latency + }, + "aggs": { + "outcomes": { + "terms": { + "field": "event.outcome", + "include": ["failure", "success"] + } + } + } +} +``` + +# System metrics + +System metrics are captured periodically (every 60 seconds by default). + +### CPU + +![image](https://user-images.githubusercontent.com/209966/135990500-f85bd8d9-b5a5-4b7c-b9e1-0759eefb8a29.png) + +Used in: [Metrics section](https://github.com/elastic/kibana/blob/00bb59713ed115343eb70d4e39059476edafbade/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts#L83) + +Noteworthy fields: `system.cpu.total.norm.pct`, `system.process.cpu.total.norm.pct` + +#### Sample document + +```json +{ + "@timestamp": "2021-09-01T10:00:00.000Z", + "processor.event": "metric", + "metricset.name": "app", + "system.process.cpu.total.norm.pct": 0.003, + "system.cpu.total.norm.pct": 0.28 +} +``` + +#### Query + +```json +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "terms": { "metricset.name": ["app"] } } + ] + } + }, + "aggs": { + "systemCPUAverage": { "avg": { "field": "system.cpu.total.norm.pct" } }, + "processCPUAverage": { + "avg": { "field": "system.process.cpu.total.norm.pct" } + } + } +} +``` + +### Memory + +![image](https://user-images.githubusercontent.com/209966/135990556-31716990-2812-46c3-a926-8c2a64c7c89f.png) + +Noteworthy fields: `system.memory.actual.free`, `system.memory.total`, + +#### Sample document + +```json +{ + "@timestamp": "2021-09-01T10:00:00.000Z", + "processor.event": "metric", + "metricset.name": "app", + "system.memory.actual.free": 13182939136, + "system.memory.total": 15735697408 +} +``` + +#### Query + +```js +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] }}, + { "terms": { "metricset.name": ["app"] }} + + // ensure the memory fields exists + { "exists": { "field": "system.memory.actual.free" }}, + { "exists": { "field": "system.memory.total" }}, + ] + } + }, + "aggs": { + "memoryUsedAvg": { + "avg": { + "script": { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']" + } + } + } + } +} +``` + +Above example is overly simplified. In reality [we do a bit more](https://github.com/elastic/kibana/blob/fe9b5332e157fd456f81aecfd4ffa78d9e511a66/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts#L51-L71) to properly calculate memory usage inside containers + + + +# Transaction breakdown metrics (`transaction_breakdown`) + +A pre-aggregations of transaction documents where `transaction.breakdown.count` is the number of original transactions. + +Noteworthy fields: `transaction.name`, `transaction.type` + +#### Sample document + +```json +{ + "@timestamp": "2021-09-27T21:59:59.828Z", + "processor.event": "metric", + "metricset.name": "transaction_breakdown", + "transaction.breakdown.count": 12, + "transaction.name": "GET /api/products", + "transaction.type": "request" +} +} +``` + +# Span breakdown metrics (`span_breakdown`) + +A pre-aggregations of span documents where `span.self_time.count` is the number of original spans. Measures the "self-time" for a span type, and optional subtype, within a transaction group. + +Span breakdown metrics are used to power the "Time spent by span type" graph. Agents collect summarized metrics about the timings of spans, broken down by `span.type`. + +![image](https://user-images.githubusercontent.com/209966/135990865-9077ae3e-a7a4-4b5d-bdce-41dc832689ea.png) + +Used in: ["Time spent by span type" chart](https://github.com/elastic/kibana/blob/723370ab23573e50b3524a62c6b9998f2042423d/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts#L48-L87) + +Noteworthy fields: `transaction.name`, `transaction.type`, `span.type`, `span.subtype`, `span.self_time.*` + +#### Sample document + +```json +{ + "@timestamp": "2021-09-27T21:59:59.828Z", + "processor.event": "metric", + "metricset.name": "span_breakdown", + "transaction.name": "GET /api/products", + "transaction.type": "request", + "span.self_time.sum.us": 1028, + "span.self_time.count": 12, + "span.type": "db", + "span.subtype": "elasticsearch" +} +``` + +#### Query + +```json +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "terms": { "metricset.name": ["span_breakdown"] } } + ] + } + }, + "aggs": { + "total_self_time": { "sum": { "field": "span.self_time.sum.us" } }, + "types": { + "terms": { "field": "span.type" }, + "aggs": { + "subtypes": { + "terms": { "field": "span.subtype" }, + "aggs": { + "self_time_per_subtype": { + "sum": { "field": "span.self_time.sum.us" } + } + } + } + } + } + } +} +``` + +# Service destination metrics + +Pre-aggregations of span documents, where `span.destination.service.response_time.count` is the number of original spans. +These metrics measure the count and total duration of requests from one service to another service. + +![image](https://user-images.githubusercontent.com/209966/135990117-170070da-2fc5-4014-a597-0dda0970854c.png) + +Used in: [Dependencies (latency)](https://github.com/elastic/kibana/blob/00bb59713ed115343eb70d4e39059476edafbade/x-pack/plugins/apm/server/lib/backends/get_latency_charts_for_backend.ts#L68-L79), [Dependencies (throughput)](https://github.com/elastic/kibana/blob/00bb59713ed115343eb70d4e39059476edafbade/x-pack/plugins/apm/server/lib/backends/get_throughput_charts_for_backend.ts#L67-L74) and [Service Map](https://github.com/elastic/kibana/blob/00bb59713ed115343eb70d4e39059476edafbade/x-pack/plugins/apm/server/lib/service_map/get_service_map_backend_node_info.ts#L57-L67) + +Noteworthy fields: `span.destination.service.*` + +#### Sample document + +A pre-aggregated document with 73 span requests from opbeans-ruby to elasticsearch, and a combined latency of 1554ms + +```json +{ + "@timestamp": "2021-09-01T10:00:00.000Z", + "processor.event": "metric", + "metricset.name": "service_destination", + "service.name": "opbeans-ruby", + "span.destination.service.response_time.count": 73, + "span.destination.service.response_time.sum.us": 1554192, + "span.destination.service.resource": "elasticsearch", + "event.outcome": "success" +} +``` + +### Latency + +The latency between a service and an (external) endpoint + +```json +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "term": { "metricset.name": "service_destination" } }, + { "term": { "span.destination.service.resource": "elasticsearch" } } + ] + } + }, + "aggs": { + "latency_sum": { + "sum": { "field": "span.destination.service.response_time.sum.us" } + }, + "latency_count": { + "sum": { "field": "span.destination.service.response_time.count" } + } + } +} +``` + +### Throughput + +Captures the number of requests made from a service to an (external) endpoint + + +#### Query + +```json +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "term": { "metricset.name": "service_destination" } }, + { "term": { "span.destination.service.resource": "elasticsearch" } } + ] + } + }, + "aggs": { + "throughput": { + "rate": { + "field": "span.destination.service.response_time.count", + "unit": "minute" + } + } + } +} +``` + +## Common filters + +Most Elasticsearch queries will need to have one or more filters. There are a couple of reasons for adding filters: + +- correctness: Running an aggregation on unrelated documents will produce incorrect results +- stability: Running an aggregation on unrelated documents could cause the entire query to fail +- performance: limiting the number of documents will make the query faster + +```js +{ + "query": { + "bool": { + "filter": [ + // service name + { "term": { "service.name": "opbeans-go" }}, + + // service environment + { "term": { "service.environment": "testing" }} + + // transaction type + { "term": { "transaction.type": "request" }} + + // event type (possible values : transaction, span, metric, error) + { "terms": { "processor.event": ["metric"] }}, + + // metric set is a subtype of `processor.event: metric` + { "terms": { "metricset.name": ["transaction"] }}, + + // time range + { + "range": { + "@timestamp": { + "gte": 1633000560000, + "lte": 1633001498988, + "format": "epoch_millis" + } + } + } + ] + } + }, +``` diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 5bc365e35cb2f..a6eba1aada85c 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -4,6 +4,7 @@ "name": "APM UI", "githubTeam": "apm-ui" }, + "description": "The user interface for Elastic APM", "version": "8.0.0", "kibanaVersion": "kibana", "requiredPlugins": [ diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx index 80c50aac13f0e..2de6f1d063522 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx @@ -11,13 +11,13 @@ import { EuiFlexGroup, EuiTitle, EuiFlexItem } from '@elastic/eui'; import { RumOverview } from '../RumDashboard'; import { CsmSharedContextProvider } from './CsmSharedContext'; import { WebApplicationSelect } from './Panels/WebApplicationSelect'; -import { DatePicker } from '../../shared/DatePicker'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { UxEnvironmentFilter } from '../../shared/EnvironmentFilter'; import { UserPercentile } from './UserPercentile'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { KibanaPageTemplateProps } from '../../../../../../../src/plugins/kibana_react/public'; import { useHasRumData } from './hooks/useHasRumData'; +import { RumDatePicker } from './rum_datepicker'; import { EmptyStateLoading } from './empty_state_loading'; export const DASHBOARD_LABEL = i18n.translate('xpack.apm.ux.title', { @@ -88,7 +88,7 @@ function PageHeader() { </EuiTitle> </EuiFlexItem> <EuiFlexItem style={{ alignItems: 'flex-end', ...datePickerStyle }}> - <DatePicker /> + <RumDatePicker /> </EuiFlexItem> </EuiFlexGroup> <EuiFlexGroup wrap> diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.test.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.test.tsx new file mode 100644 index 0000000000000..afb0e9ef37d51 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.test.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 { EuiSuperDatePicker } from '@elastic/eui'; +import { waitFor } from '@testing-library/react'; +import { mount } from 'enzyme'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import React, { ReactNode } from 'react'; +import qs from 'query-string'; +import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; +import { UrlParamsContext } from '../../../../context/url_params_context/url_params_context'; +import { RumDatePicker } from './'; +import { useLocation } from 'react-router-dom'; + +let history: MemoryHistory; +let mockHistoryPush: jest.SpyInstance; +let mockHistoryReplace: jest.SpyInstance; + +const mockRefreshTimeRange = jest.fn(); + +function MockUrlParamsProvider({ children }: { children: ReactNode }) { + const location = useLocation(); + + const urlParams = qs.parse(location.search, { + parseBooleans: true, + parseNumbers: true, + }); + + return ( + <UrlParamsContext.Provider + value={{ + rangeId: 0, + refreshTimeRange: mockRefreshTimeRange, + urlParams, + uxUiFilters: {}, + }} + children={children} + /> + ); +} + +function mountDatePicker( + params: { + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: boolean; + refreshInterval?: number; + } = {} +) { + const setTimeSpy = jest.fn(); + const getTimeSpy = jest.fn().mockReturnValue({}); + + history = createMemoryHistory({ + initialEntries: [`/?${qs.stringify(params)}`], + }); + + jest.spyOn(console, 'error').mockImplementation(() => null); + mockHistoryPush = jest.spyOn(history, 'push'); + mockHistoryReplace = jest.spyOn(history, 'replace'); + + const wrapper = mount( + <MockApmPluginContextWrapper + history={history} + value={ + { + plugins: { + data: { + query: { + timefilter: { + timefilter: { setTime: setTimeSpy, getTime: getTimeSpy }, + }, + }, + }, + }, + } as any + } + > + <MockUrlParamsProvider> + <RumDatePicker /> + </MockUrlParamsProvider> + </MockApmPluginContextWrapper> + ); + + return { wrapper, setTimeSpy, getTimeSpy }; +} + +describe('RumDatePicker', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('sets default query params in the URL', () => { + mountDatePicker(); + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace).toHaveBeenCalledWith( + expect.objectContaining({ + search: 'rangeFrom=now-15m&rangeTo=now', + }) + ); + }); + + it('adds missing `rangeFrom` to url', () => { + mountDatePicker({ rangeTo: 'now', refreshInterval: 5000 }); + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace).toHaveBeenCalledWith( + expect.objectContaining({ + search: 'rangeFrom=now-15m&rangeTo=now&refreshInterval=5000', + }) + ); + }); + + it('does not set default query params in the URL when values already defined', () => { + mountDatePicker({ + rangeFrom: 'now-1d', + rangeTo: 'now', + refreshPaused: false, + refreshInterval: 5000, + }); + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + }); + + it('updates the URL when the date range changes', () => { + const { wrapper } = mountDatePicker(); + + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + + wrapper.find(EuiSuperDatePicker).props().onTimeChange({ + start: 'now-90m', + end: 'now-60m', + isInvalid: false, + isQuickSelection: true, + }); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenLastCalledWith( + expect.objectContaining({ + search: 'rangeFrom=now-90m&rangeTo=now-60m', + }) + ); + }); + + it('enables auto-refresh when refreshPaused is false', async () => { + jest.useFakeTimers(); + const { wrapper } = mountDatePicker({ + refreshPaused: false, + refreshInterval: 1000, + }); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + jest.advanceTimersByTime(2500); + await waitFor(() => {}); + expect(mockRefreshTimeRange).toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('disables auto-refresh when refreshPaused is true', async () => { + jest.useFakeTimers(); + mountDatePicker({ refreshPaused: true, refreshInterval: 1000 }); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + await waitFor(() => {}); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + }); + + describe('if both `rangeTo` and `rangeFrom` is set', () => { + it('calls setTime ', async () => { + const { setTimeSpy } = mountDatePicker({ + rangeTo: 'now-20m', + rangeFrom: 'now-22m', + }); + expect(setTimeSpy).toHaveBeenCalledWith({ + to: 'now-20m', + from: 'now-22m', + }); + }); + + it('does not update the url', () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + }); + }); + + describe('if `rangeFrom` is missing from the urlParams', () => { + beforeEach(() => { + mountDatePicker({ rangeTo: 'now-5m' }); + }); + + it('updates the url with the default `rangeFrom` ', async () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeFrom=now-15m' + ); + }); + + it('preserves `rangeTo`', () => { + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeTo=now-5m' + ); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.tsx new file mode 100644 index 0000000000000..9bc18d772a4a1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { useUxUrlParams } from '../../../../context/url_params_context/use_ux_url_params'; +import { useDateRangeRedirect } from '../../../../hooks/use_date_range_redirect'; +import { DatePicker } from '../../../shared/DatePicker'; + +export function RumDatePicker() { + const { + urlParams: { rangeFrom, rangeTo, refreshPaused, refreshInterval }, + refreshTimeRange, + } = useUxUrlParams(); + + const { redirect, isDateRangeSet } = useDateRangeRedirect(); + + if (!isDateRangeSet) { + redirect(); + } + + return ( + <DatePicker + rangeFrom={rangeFrom} + rangeTo={rangeTo} + refreshPaused={refreshPaused} + refreshInterval={refreshInterval} + onTimeRangeRefresh={({ start, end }) => { + refreshTimeRange({ rangeFrom: start, rangeTo: end }); + }} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 7aafb27aa18f3..13d70438ef3b0 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -25,6 +25,7 @@ import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; import { ITableColumn, ManagedTable } from '../../../shared/managed_table'; import { AnomalyDetectionApiResponse } from './index'; import { LegacyJobsCallout } from './legacy_jobs_callout'; +import { MLJobsAwaitingNodeWarning } from '../../../../../../ml/public'; type Jobs = AnomalyDetectionApiResponse['jobs']; @@ -67,6 +68,7 @@ export function JobsList({ data, status, onAddEnvironments }: Props) { return ( <> + <MLJobsAwaitingNodeWarning jobIds={jobs.map((j) => j.job_id)} /> <EuiText color="subdued"> <FormattedMessage id="xpack.apm.settings.anomalyDetection.jobList.mlDescriptionText" diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx index 16120a6f5b429..3b4deac794df0 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx @@ -32,7 +32,14 @@ import { useBreakpoints } from '../../../hooks/use_breakpoints'; export function BackendDetailOverview() { const { path: { backendName }, - query: { rangeFrom, rangeTo, environment, kuery }, + query: { + rangeFrom, + rangeTo, + refreshInterval, + refreshPaused, + environment, + kuery, + }, } = useApmParams('/backends/{backendName}/overview'); const apmRouter = useApmRouter(); @@ -41,7 +48,14 @@ export function BackendDetailOverview() { { title: DependenciesInventoryTitle, href: apmRouter.link('/backends', { - query: { rangeFrom, rangeTo, environment, kuery }, + query: { + rangeFrom, + rangeTo, + refreshInterval, + refreshPaused, + environment, + kuery, + }, }), }, { @@ -51,6 +65,8 @@ export function BackendDetailOverview() { query: { rangeFrom, rangeTo, + refreshInterval, + refreshPaused, environment, kuery, }, diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx index 523d8b1840fc8..9956452c565b3 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx @@ -58,7 +58,11 @@ function Wrapper({ history.replace({ pathname: '/services/the-service-name/transactions/view', - search: fromQuery({ transactionName: 'the-transaction-name' }), + search: fromQuery({ + transactionName: 'the-transaction-name', + rangeFrom: 'now-15m', + rangeTo: 'now', + }), }); const mockPluginContext = merge({}, mockApmPluginContextValue, { @@ -73,14 +77,7 @@ function Wrapper({ history={history} value={mockPluginContext} > - <MockUrlParamsContextProvider - params={{ - rangeFrom: 'now-15m', - rangeTo: 'now', - start: 'mystart', - end: 'myend', - }} - > + <MockUrlParamsContextProvider> {children} </MockUrlParamsContextProvider> </MockApmPluginContextWrapper> diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx index d6300b7c80f1c..212489bf12cb4 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx @@ -51,7 +51,9 @@ const stories: Meta<Args> = { createCallApmApi(coreMock); return ( - <MemoryRouter initialEntries={['/service-map']}> + <MemoryRouter + initialEntries={['/service-map?rangeFrom=now-15m&rangeTo=now']} + > <KibanaReactContext.Provider> <MockUrlParamsContextProvider> <MockApmPluginContextWrapper> diff --git a/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx index 9e3abb7cfd935..796be0659e617 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx @@ -16,8 +16,6 @@ import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_ap import { LicenseContext } from '../../../context/license/license_context'; import * as useFetcherModule from '../../../hooks/use_fetcher'; import { ServiceMap } from '.'; -import { UrlParamsProvider } from '../../../context/url_params_context/url_params_context'; -import { Router } from 'react-router-dom'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; const history = createMemoryHistory(); @@ -49,15 +47,15 @@ const expiredLicense = new License({ }); function createWrapper(license: License | null) { + history.replace('/service-map?rangeFrom=now-15m&rangeTo=now'); + return ({ children }: { children?: ReactNode }) => { return ( <EuiThemeProvider> <KibanaReactContext.Provider> <LicenseContext.Provider value={license || undefined}> - <MockApmPluginContextWrapper> - <Router history={history}> - <UrlParamsProvider>{children}</UrlParamsProvider> - </Router> + <MockApmPluginContextWrapper history={history}> + {children} </MockApmPluginContextWrapper> </LicenseContext.Provider> </KibanaReactContext.Provider> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx index 9057d4c6667b8..bd0ff4c87c3be 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx @@ -56,7 +56,11 @@ function Wrapper({ history.replace({ pathname: '/services/the-service-name/transactions/view', - search: fromQuery({ transactionName: 'the-transaction-name' }), + search: fromQuery({ + transactionName: 'the-transaction-name', + rangeFrom: 'now-15m', + rangeTo: 'now', + }), }); const mockPluginContext = merge({}, mockApmPluginContextValue, { diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index e031af6464187..a1b24fc516664 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -94,11 +94,14 @@ describe('TransactionOverview', () => { it('should redirect to first type', () => { setup({ serviceTransactionTypes: ['firstType', 'secondType'], - urlParams: {}, + urlParams: { + rangeFrom: 'now-15m', + rangeTo: 'now', + }, }); expect(history.replace).toHaveBeenCalledWith( expect.objectContaining({ - search: 'transactionType=firstType', + search: 'rangeFrom=now-15m&rangeTo=now&transactionType=firstType', }) ); }); @@ -112,6 +115,8 @@ describe('TransactionOverview', () => { serviceTransactionTypes: ['firstType'], urlParams: { transactionType: 'firstType', + rangeFrom: 'now-15m', + rangeTo: 'now', }, }); diff --git a/x-pack/plugins/apm/public/components/routing/app_root.tsx b/x-pack/plugins/apm/public/components/routing/app_root.tsx index c32828eca2f69..bae41b055874a 100644 --- a/x-pack/plugins/apm/public/components/routing/app_root.tsx +++ b/x-pack/plugins/apm/public/components/routing/app_root.tsx @@ -32,6 +32,7 @@ import { TimeRangeIdContextProvider } from '../../context/time_range_id/time_ran import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; import { ApmPluginStartDeps } from '../../plugin'; import { ApmHeaderActionMenu } from '../shared/apm_header_action_menu'; +import { RedirectWithDefaultDateRange } from '../shared/redirect_with_default_date_range'; import { apmRouter } from './apm_route_config'; import { TrackPageview } from './track_pageview'; @@ -58,24 +59,26 @@ export function ApmAppRoot({ <i18nCore.Context> <TimeRangeIdContextProvider> <RouterProvider history={history} router={apmRouter as any}> - <TrackPageview> - <BreadcrumbsContextProvider> - <UrlParamsProvider> - <LicenseProvider> - <AnomalyDetectionJobsContextProvider> - <InspectorContextProvider> - <ApmThemeProvider> - <MountApmHeaderActionMenu /> + <RedirectWithDefaultDateRange> + <TrackPageview> + <BreadcrumbsContextProvider> + <UrlParamsProvider> + <LicenseProvider> + <AnomalyDetectionJobsContextProvider> + <InspectorContextProvider> + <ApmThemeProvider> + <MountApmHeaderActionMenu /> - <Route component={ScrollToTopOnPathChange} /> - <RouteRenderer /> - </ApmThemeProvider> - </InspectorContextProvider> - </AnomalyDetectionJobsContextProvider> - </LicenseProvider> - </UrlParamsProvider> - </BreadcrumbsContextProvider> - </TrackPageview> + <Route component={ScrollToTopOnPathChange} /> + <RouteRenderer /> + </ApmThemeProvider> + </InspectorContextProvider> + </AnomalyDetectionJobsContextProvider> + </LicenseProvider> + </UrlParamsProvider> + </BreadcrumbsContextProvider> + </TrackPageview> + </RedirectWithDefaultDateRange> </RouterProvider> </TimeRangeIdContextProvider> </i18nCore.Context> diff --git a/x-pack/plugins/apm/public/components/routing/home/index.tsx b/x-pack/plugins/apm/public/components/routing/home/index.tsx index 1736a22e9b540..30e641f142b25 100644 --- a/x-pack/plugins/apm/public/components/routing/home/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/home/index.tsx @@ -63,12 +63,14 @@ export const home = { rangeTo: t.string, kuery: t.string, }), + t.partial({ + refreshPaused: t.union([t.literal('true'), t.literal('false')]), + refreshInterval: t.string, + }), ]), }), defaults: { query: { - rangeFrom: 'now-15m', - rangeTo: 'now', environment: ENVIRONMENT_ALL.value, kuery: '', }, diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx index 9b87cc338bb9b..16cba23da6423 100644 --- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -83,14 +83,14 @@ export const serviceDetail = { comparisonType: t.string, latencyAggregationType: t.string, transactionType: t.string, + refreshPaused: t.union([t.literal('true'), t.literal('false')]), + refreshInterval: t.string, }), ]), }), ]), defaults: { query: { - rangeFrom: 'now-15m', - rangeTo: 'now', kuery: '', environment: ENVIRONMENT_ALL.value, }, diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx index 6a4ab5d7d9bc5..ac0b8a1517b20 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx @@ -5,10 +5,13 @@ * 2.0. */ -import { EuiPageHeaderProps, EuiPageTemplateProps } from '@elastic/eui'; +import { EuiPageHeaderProps } from '@elastic/eui'; import React from 'react'; import { useLocation } from 'react-router-dom'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { + useKibana, + KibanaPageTemplateProps, +} from '../../../../../../../src/plugins/kibana_react/public'; import { useFetcher } from '../../../hooks/use_fetcher'; import { ApmPluginStartDeps } from '../../../plugin'; import { ApmEnvironmentFilter } from '../../shared/EnvironmentFilter'; @@ -35,7 +38,7 @@ export function ApmMainTemplate({ pageTitle?: React.ReactNode; pageHeader?: EuiPageHeaderProps; children: React.ReactNode; -} & EuiPageTemplateProps) { +} & KibanaPageTemplateProps) { const location = useLocation(); const { services } = useKibana<ApmPluginStartDeps>(); diff --git a/x-pack/plugins/apm/public/components/routing/track_pageview.tsx b/x-pack/plugins/apm/public/components/routing/track_pageview.tsx index 7f4a03cae90be..af0682a56ec2b 100644 --- a/x-pack/plugins/apm/public/components/routing/track_pageview.tsx +++ b/x-pack/plugins/apm/public/components/routing/track_pageview.tsx @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; import { useRoutePath } from '@kbn/typed-react-router-config'; import { useTrackPageview } from '../../../../observability/public'; diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx index ada93ff3a0344..737c1a54a2f09 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx @@ -8,40 +8,62 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import { waitFor } from '@testing-library/react'; import { mount } from 'enzyme'; -import { createMemoryHistory } from 'history'; -import React, { ReactNode } from 'react'; -import { Router } from 'react-router-dom'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import qs from 'query-string'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { UrlParamsContext } from '../../../context/url_params_context/url_params_context'; -import { ApmUrlParams } from '../../../context/url_params_context/types'; import { DatePicker } from './'; -const history = createMemoryHistory(); +let history: MemoryHistory; const mockRefreshTimeRange = jest.fn(); -function MockUrlParamsProvider({ - urlParams = {}, - children, -}: { - children: ReactNode; - urlParams?: ApmUrlParams; -}) { +let mockHistoryPush: jest.SpyInstance; +let mockHistoryReplace: jest.SpyInstance; + +function DatePickerWrapper() { + const location = useLocation(); + + const { rangeFrom, rangeTo, refreshInterval, refreshPaused } = qs.parse( + location.search, + { + parseNumbers: true, + parseBooleans: true, + } + ) as { + rangeFrom?: string; + rangeTo?: string; + refreshInterval?: number; + refreshPaused?: boolean; + }; + return ( - <UrlParamsContext.Provider - value={{ - rangeId: 0, - refreshTimeRange: mockRefreshTimeRange, - urlParams, - uxUiFilters: {}, - }} - children={children} + <DatePicker + rangeFrom={rangeFrom} + rangeTo={rangeTo} + refreshInterval={refreshInterval} + refreshPaused={refreshPaused} + onTimeRangeRefresh={mockRefreshTimeRange} /> ); } -function mountDatePicker(urlParams?: ApmUrlParams) { +function mountDatePicker(initialParams: { + rangeFrom?: string; + rangeTo?: string; + refreshInterval?: number; + refreshPaused?: boolean; +}) { const setTimeSpy = jest.fn(); const getTimeSpy = jest.fn().mockReturnValue({}); + + history = createMemoryHistory({ + initialEntries: [`/?${qs.stringify(initialParams)}`], + }); + + mockHistoryPush = jest.spyOn(history, 'push'); + mockHistoryReplace = jest.spyOn(history, 'replace'); + const wrapper = mount( <MockApmPluginContextWrapper value={ @@ -57,12 +79,9 @@ function mountDatePicker(urlParams?: ApmUrlParams) { }, } as any } + history={history} > - <Router history={history}> - <MockUrlParamsProvider urlParams={urlParams}> - <DatePicker /> - </MockUrlParamsProvider> - </Router> + <DatePickerWrapper /> </MockApmPluginContextWrapper> ); @@ -70,12 +89,8 @@ function mountDatePicker(urlParams?: ApmUrlParams) { } describe('DatePicker', () => { - let mockHistoryPush: jest.SpyInstance; - let mockHistoryReplace: jest.SpyInstance; beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => null); - mockHistoryPush = jest.spyOn(history, 'push'); - mockHistoryReplace = jest.spyOn(history, 'replace'); }); afterAll(() => { @@ -86,47 +101,24 @@ describe('DatePicker', () => { jest.resetAllMocks(); }); - it('sets default query params in the URL', () => { - mountDatePicker(); - expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - expect(mockHistoryReplace).toHaveBeenCalledWith( - expect.objectContaining({ - search: 'rangeFrom=now-15m&rangeTo=now', - }) - ); - }); - - it('adds missing `rangeFrom` to url', () => { - mountDatePicker({ rangeTo: 'now', refreshInterval: 5000 }); - expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - expect(mockHistoryReplace).toHaveBeenCalledWith( - expect.objectContaining({ search: 'rangeFrom=now-15m&rangeTo=now' }) - ); - }); - - it('does not set default query params in the URL when values already defined', () => { - mountDatePicker({ - rangeFrom: 'now-1d', + it('updates the URL when the date range changes', () => { + const { wrapper } = mountDatePicker({ + rangeFrom: 'now-15m', rangeTo: 'now', - refreshPaused: false, - refreshInterval: 5000, }); + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); - }); - it('updates the URL when the date range changes', () => { - const { wrapper } = mountDatePicker(); - expect(mockHistoryReplace).toHaveBeenCalledTimes(1); wrapper.find(EuiSuperDatePicker).props().onTimeChange({ - start: 'updated-start', - end: 'updated-end', + start: 'now-90m', + end: 'now-60m', isInvalid: false, isQuickSelection: true, }); expect(mockHistoryPush).toHaveBeenCalledTimes(1); expect(mockHistoryPush).toHaveBeenLastCalledWith( expect.objectContaining({ - search: 'rangeFrom=updated-start&rangeTo=updated-end', + search: 'rangeFrom=now-90m&rangeTo=now-60m', }) ); }); @@ -134,6 +126,8 @@ describe('DatePicker', () => { it('enables auto-refresh when refreshPaused is false', async () => { jest.useFakeTimers(); const { wrapper } = mountDatePicker({ + rangeFrom: 'now-15m', + rangeTo: 'now', refreshPaused: false, refreshInterval: 1000, }); @@ -146,7 +140,12 @@ describe('DatePicker', () => { it('disables auto-refresh when refreshPaused is true', async () => { jest.useFakeTimers(); - mountDatePicker({ refreshPaused: true, refreshInterval: 1000 }); + mountDatePicker({ + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshPaused: true, + refreshInterval: 1000, + }); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); await waitFor(() => {}); @@ -169,29 +168,4 @@ describe('DatePicker', () => { expect(mockHistoryReplace).toHaveBeenCalledTimes(0); }); }); - - describe('if `rangeFrom` is missing from the urlParams', () => { - let setTimeSpy: jest.Mock; - beforeEach(() => { - const res = mountDatePicker({ rangeTo: 'now-5m' }); - setTimeSpy = res.setTimeSpy; - }); - - it('does not call setTime', async () => { - expect(setTimeSpy).toHaveBeenCalledTimes(0); - }); - - it('updates the url with the default `rangeFrom` ', async () => { - expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - expect(mockHistoryReplace.mock.calls[0][0].search).toContain( - 'rangeFrom=now-15m' - ); - }); - - it('preserves `rangeTo`', () => { - expect(mockHistoryReplace.mock.calls[0][0].search).toContain( - 'rangeTo=now-5m' - ); - }); - }); }); diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx index 6772438fed01b..12cc137d62142 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx @@ -10,12 +10,23 @@ import React, { useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { clearCache } from '../../../services/rest/callApi'; import { fromQuery, toQuery } from '../Links/url_helpers'; -import { TimePickerQuickRange, TimePickerTimeDefaults } from './typings'; +import { TimePickerQuickRange } from './typings'; -export function DatePicker() { +export function DatePicker({ + rangeFrom, + rangeTo, + refreshPaused, + refreshInterval, + onTimeRangeRefresh, +}: { + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: boolean; + refreshInterval?: number; + onTimeRangeRefresh: (range: { start: string; end: string }) => void; +}) { const history = useHistory(); const location = useLocation(); const { core, plugins } = useApmPluginContext(); @@ -24,10 +35,6 @@ export function DatePicker() { UI_SETTINGS.TIMEPICKER_QUICK_RANGES ); - const timePickerTimeDefaults = core.uiSettings.get<TimePickerTimeDefaults>( - UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS - ); - const commonlyUsedRanges = timePickerQuickRanges.map( ({ from, to, display }) => ({ start: from, @@ -36,8 +43,6 @@ export function DatePicker() { }) ); - const { urlParams, refreshTimeRange } = useUrlParams(); - function updateUrl(nextQuery: { rangeFrom?: string; rangeTo?: string; @@ -54,13 +59,16 @@ export function DatePicker() { } function onRefreshChange({ - isPaused, - refreshInterval, + nextRefreshPaused, + nextRefreshInterval, }: { - isPaused: boolean; - refreshInterval: number; + nextRefreshPaused: boolean; + nextRefreshInterval: number; }) { - updateUrl({ refreshPaused: isPaused, refreshInterval }); + updateUrl({ + refreshPaused: nextRefreshPaused, + refreshInterval: nextRefreshInterval, + }); } function onTimeChange({ start, end }: { start: string; end: string }) { @@ -69,53 +77,32 @@ export function DatePicker() { useEffect(() => { // set time if both to and from are given in the url - if (urlParams.rangeFrom && urlParams.rangeTo) { + if (rangeFrom && rangeTo) { plugins.data.query.timefilter.timefilter.setTime({ - from: urlParams.rangeFrom, - to: urlParams.rangeTo, + from: rangeFrom, + to: rangeTo, }); return; } - - // read time from state and update the url - const timePickerSharedState = - plugins.data.query.timefilter.timefilter.getTime(); - - history.replace({ - ...location, - search: fromQuery({ - ...toQuery(location.search), - rangeFrom: - urlParams.rangeFrom ?? - timePickerSharedState.from ?? - timePickerTimeDefaults.from, - rangeTo: - urlParams.rangeTo ?? - timePickerSharedState.to ?? - timePickerTimeDefaults.to, - }), - }); - }, [ - urlParams.rangeFrom, - urlParams.rangeTo, - plugins, - history, - location, - timePickerTimeDefaults, - ]); + }, [rangeFrom, rangeTo, plugins]); return ( <EuiSuperDatePicker - start={urlParams.rangeFrom} - end={urlParams.rangeTo} - isPaused={urlParams.refreshPaused} - refreshInterval={urlParams.refreshInterval} + start={rangeFrom} + end={rangeTo} + isPaused={refreshPaused} + refreshInterval={refreshInterval} onTimeChange={onTimeChange} onRefresh={({ start, end }) => { clearCache(); - refreshTimeRange({ rangeFrom: start, rangeTo: end }); + onTimeRangeRefresh({ start, end }); + }} + onRefreshChange={({ + isPaused: nextRefreshPaused, + refreshInterval: nextRefreshInterval, + }) => { + onRefreshChange({ nextRefreshPaused, nextRefreshInterval }); }} - onRefreshChange={onRefreshChange} showUpdateButton={true} commonlyUsedRanges={commonlyUsedRanges} /> diff --git a/x-pack/plugins/apm/public/components/shared/KeyValueTable/index.tsx b/x-pack/plugins/apm/public/components/shared/KeyValueTable/index.tsx index 13aa3696eda42..e9525728bc3c5 100644 --- a/x-pack/plugins/apm/public/components/shared/KeyValueTable/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/KeyValueTable/index.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { castArray } from 'lodash'; import React, { TableHTMLAttributes } from 'react'; import { EuiTable, @@ -26,16 +26,32 @@ export function KeyValueTable({ return ( <EuiTable compressed {...tableProps}> <EuiTableBody> - {keyValuePairs.map(({ key, value }) => ( - <EuiTableRow key={key}> - <EuiTableRowCell> - <strong data-test-subj="dot-key">{key}</strong> - </EuiTableRowCell> - <EuiTableRowCell data-test-subj="value"> - <FormattedValue value={value} /> - </EuiTableRowCell> - </EuiTableRow> - ))} + {keyValuePairs.map(({ key, value }) => { + const asArray = castArray(value); + const valueList = + asArray.length <= 1 ? ( + <FormattedValue value={asArray[0]} /> + ) : ( + <ul> + {asArray.map((val, index) => ( + <li> + <FormattedValue key={index} value={val} /> + </li> + ))} + </ul> + ); + + return ( + <EuiTableRow key={key}> + <EuiTableRowCell> + <strong data-test-subj="dot-key">{key}</strong> + </EuiTableRowCell> + <EuiTableRowCell data-test-subj="value"> + {valueList} + </EuiTableRowCell> + </EuiTableRow> + ); + })} </EuiTableBody> </EuiTable> ); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLExplorerLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLExplorerLink.test.tsx index 44e33e6bf419d..61db277f90d7f 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLExplorerLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLExplorerLink.test.tsx @@ -10,7 +10,8 @@ import React from 'react'; import { getRenderedHref } from '../../../../utils/testHelpers'; import { MLExplorerLink } from './MLExplorerLink'; -describe('MLExplorerLink', () => { +// FLAKY: https://github.com/elastic/kibana/issues/113695 +describe.skip('MLExplorerLink', () => { it('should produce the correct URL with jobId', async () => { const href = await getRenderedHref( () => ( diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/ErrorMetadata.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/ErrorMetadata.test.tsx deleted file mode 100644 index f936941923e41..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/ErrorMetadata.test.tsx +++ /dev/null @@ -1,139 +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 { render } from '@testing-library/react'; -import React, { ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { ErrorMetadata } from '.'; -import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; -import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; -import { - expectTextsInDocument, - expectTextsNotInDocument, -} from '../../../../utils/testHelpers'; - -function Wrapper({ children }: { children?: ReactNode }) { - return ( - <MemoryRouter> - <MockApmPluginContextWrapper>{children}</MockApmPluginContextWrapper> - </MemoryRouter> - ); -} - -const renderOptions = { - wrapper: Wrapper, -}; - -function getError() { - return { - labels: { someKey: 'labels value' }, - http: { someKey: 'http value' }, - host: { someKey: 'host value' }, - container: { someKey: 'container value' }, - service: { someKey: 'service value' }, - process: { someKey: 'process value' }, - agent: { someKey: 'agent value' }, - url: { someKey: 'url value' }, - user: { someKey: 'user value' }, - notIncluded: 'not included value', - error: { - id: '7efbc7056b746fcb', - notIncluded: 'error not included value', - custom: { - someKey: 'custom value', - }, - }, - } as unknown as APMError; -} - -describe('ErrorMetadata', () => { - it('should render a error with all sections', () => { - const error = getError(); - const output = render(<ErrorMetadata error={error} />, renderOptions); - - // sections - expectTextsInDocument(output, [ - 'Labels', - 'HTTP', - 'Host', - 'Container', - 'Service', - 'Process', - 'Agent', - 'URL', - 'User', - 'Custom', - ]); - }); - - it('should render a error with all included dot notation keys', () => { - const error = getError(); - const output = render(<ErrorMetadata error={error} />, renderOptions); - - // included keys - expectTextsInDocument(output, [ - 'labels.someKey', - 'http.someKey', - 'host.someKey', - 'container.someKey', - 'service.someKey', - 'process.someKey', - 'agent.someKey', - 'url.someKey', - 'user.someKey', - 'error.custom.someKey', - ]); - - // excluded keys - expectTextsNotInDocument(output, ['notIncluded', 'error.notIncluded']); - }); - - it('should render a error with all included values', () => { - const error = getError(); - const output = render(<ErrorMetadata error={error} />, renderOptions); - - // included values - expectTextsInDocument(output, [ - 'labels value', - 'http value', - 'host value', - 'container value', - 'service value', - 'process value', - 'agent value', - 'url value', - 'user value', - 'custom value', - ]); - - // excluded values - expectTextsNotInDocument(output, [ - 'not included value', - 'error not included value', - ]); - }); - - it('should render a error with only the required sections', () => { - const error = {} as APMError; - const output = render(<ErrorMetadata error={error} />, renderOptions); - - // required sections should be found - expectTextsInDocument(output, ['Labels', 'User']); - - // optional sections should NOT be found - expectTextsNotInDocument(output, [ - 'HTTP', - 'Host', - 'Container', - 'Service', - 'Process', - 'Agent', - 'URL', - 'Custom', - ]); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx index 196a8706d5132..f6ffc34ecee02 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx @@ -6,19 +6,41 @@ */ import React, { useMemo } from 'react'; -import { ERROR_METADATA_SECTIONS } from './sections'; import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; -import { getSectionsWithRows } from '../helper'; +import { getSectionsFromFields } from '../helper'; import { MetadataTable } from '..'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { ProcessorEvent } from '../../../../../common/processor_event'; interface Props { error: APMError; } export function ErrorMetadata({ error }: Props) { - const sectionsWithRows = useMemo( - () => getSectionsWithRows(ERROR_METADATA_SECTIONS, error), - [error] + const { data: errorEvent, status } = useFetcher( + (callApmApi) => { + return callApmApi({ + endpoint: 'GET /api/apm/event_metadata/{processorEvent}/{id}', + params: { + path: { + processorEvent: ProcessorEvent.error, + id: error.error.id, + }, + }, + }); + }, + [error.error.id] + ); + + const sections = useMemo( + () => getSectionsFromFields(errorEvent?.metadata || {}), + [errorEvent?.metadata] + ); + + return ( + <MetadataTable + sections={sections} + isLoading={status === FETCH_STATUS.LOADING} + /> ); - return <MetadataTable sections={sectionsWithRows} />; } diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/sections.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/sections.ts deleted file mode 100644 index 28a64ac36660e..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/sections.ts +++ /dev/null @@ -1,39 +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 { - Section, - ERROR, - LABELS, - HTTP, - HOST, - CONTAINER, - SERVICE, - PROCESS, - AGENT, - URL, - USER, - CUSTOM_ERROR, - TRACE, - TRANSACTION, -} from '../sections'; - -export const ERROR_METADATA_SECTIONS: Section[] = [ - { ...LABELS, required: true }, - TRACE, - TRANSACTION, - ERROR, - HTTP, - HOST, - CONTAINER, - SERVICE, - PROCESS, - AGENT, - URL, - { ...USER, required: true }, - CUSTOM_ERROR, -]; diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/MetadataTable.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/MetadataTable.test.tsx index 7ccde6a9a74d6..5d5976866ba24 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/MetadataTable.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/MetadataTable.test.tsx @@ -11,7 +11,7 @@ import { MemoryRouter } from 'react-router-dom'; import { MetadataTable } from '.'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { expectTextsInDocument } from '../../../utils/testHelpers'; -import { SectionsWithRows } from './helper'; +import type { SectionDescriptor } from './types'; function Wrapper({ children }: { children?: ReactNode }) { return ( @@ -27,21 +27,20 @@ const renderOptions = { describe('MetadataTable', () => { it('shows sections', () => { - const sectionsWithRows = [ - { key: 'foo', label: 'Foo', required: true }, + const sections: SectionDescriptor[] = [ + { key: 'foo', label: 'Foo', required: true, properties: [] }, { key: 'bar', label: 'Bar', required: false, - properties: ['props.A', 'props.B'], - rows: [ - { key: 'props.A', value: 'A' }, - { key: 'props.B', value: 'B' }, + properties: [ + { field: 'props.A', value: ['A'] }, + { field: 'props.B', value: ['B'] }, ], }, - ] as unknown as SectionsWithRows; + ]; const output = render( - <MetadataTable sections={sectionsWithRows} />, + <MetadataTable sections={sections} isLoading={false} />, renderOptions ); expectTextsInDocument(output, [ @@ -56,15 +55,17 @@ describe('MetadataTable', () => { }); describe('required sections', () => { it('shows "empty state message" if no data is available', () => { - const sectionsWithRows = [ + const sectionsWithRows: SectionDescriptor[] = [ { key: 'foo', label: 'Foo', required: true, + properties: [], }, - ] as unknown as SectionsWithRows; + ]; + const output = render( - <MetadataTable sections={sectionsWithRows} />, + <MetadataTable sections={sectionsWithRows} isLoading={false} />, renderOptions ); expectTextsInDocument(output, ['Foo', 'No data available']); diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.test.tsx index d44464e2160d3..ed816b1c7a337 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.test.tsx @@ -12,7 +12,7 @@ import { expectTextsInDocument } from '../../../utils/testHelpers'; describe('Section', () => { it('shows "empty state message" if no data is available', () => { - const component = render(<Section keyValuePairs={[]} />); + const component = render(<Section properties={[]} />); expectTextsInDocument(component, ['No data available']); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.tsx index ff86083b8612d..03ae237f470c3 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.tsx @@ -10,15 +10,21 @@ import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiText } from '@elastic/eui'; import { KeyValueTable } from '../KeyValueTable'; -import { KeyValuePair } from '../../../utils/flattenObject'; interface Props { - keyValuePairs: KeyValuePair[]; + properties: Array<{ field: string; value: string[] | number[] }>; } -export function Section({ keyValuePairs }: Props) { - if (!isEmpty(keyValuePairs)) { - return <KeyValueTable keyValuePairs={keyValuePairs} />; +export function Section({ properties }: Props) { + if (!isEmpty(properties)) { + return ( + <KeyValueTable + keyValuePairs={properties.map((property) => ({ + key: property.field, + value: property.value, + }))} + /> + ); } return ( <EuiText size="s"> diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/SpanMetadata.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/SpanMetadata.test.tsx deleted file mode 100644 index 46eaba1e9e11d..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/SpanMetadata.test.tsx +++ /dev/null @@ -1,107 +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 { render } from '@testing-library/react'; -import React, { ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { SpanMetadata } from '.'; -import { Span } from '../../../../../typings/es_schemas/ui/span'; -import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; -import { - expectTextsInDocument, - expectTextsNotInDocument, -} from '../../../../utils/testHelpers'; - -function Wrapper({ children }: { children?: ReactNode }) { - return ( - <MemoryRouter> - <MockApmPluginContextWrapper>{children}</MockApmPluginContextWrapper> - </MemoryRouter> - ); -} - -const renderOptions = { - wrapper: Wrapper, -}; - -describe('SpanMetadata', () => { - describe('render', () => { - it('renders', () => { - const span = { - agent: { - ephemeral_id: 'ed8e3a4f-21d2-4a1f-bbc7-fa2064d94225', - name: 'java', - version: '1.9.1-SNAPSHOT', - }, - service: { - name: 'opbeans-java', - }, - span: { - id: '7efbc7056b746fcb', - message: { - age: { ms: 1577958057123 }, - queue: { name: 'queue name' }, - }, - }, - } as unknown as Span; - const output = render(<SpanMetadata span={span} />, renderOptions); - expectTextsInDocument(output, ['Service', 'Agent', 'Message']); - }); - }); - describe('when a span is presented', () => { - it('renders the span', () => { - const span = { - agent: { - ephemeral_id: 'ed8e3a4f-21d2-4a1f-bbc7-fa2064d94225', - name: 'java', - version: '1.9.1-SNAPSHOT', - }, - service: { - name: 'opbeans-java', - }, - span: { - id: '7efbc7056b746fcb', - http: { - response: { status_code: 200 }, - }, - subtype: 'http', - type: 'external', - message: { - age: { ms: 1577958057123 }, - queue: { name: 'queue name' }, - }, - }, - } as unknown as Span; - const output = render(<SpanMetadata span={span} />, renderOptions); - expectTextsInDocument(output, ['Service', 'Agent', 'Span', 'Message']); - }); - }); - describe('when there is no id inside span', () => { - it('does not show the section', () => { - const span = { - agent: { - ephemeral_id: 'ed8e3a4f-21d2-4a1f-bbc7-fa2064d94225', - name: 'java', - version: '1.9.1-SNAPSHOT', - }, - service: { - name: 'opbeans-java', - }, - span: { - http: { - response: { status_code: 200 }, - }, - subtype: 'http', - type: 'external', - }, - } as unknown as Span; - const output = render(<SpanMetadata span={span} />, renderOptions); - expectTextsInDocument(output, ['Service', 'Agent']); - expectTextsNotInDocument(output, ['Span', 'Message']); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx index feefcea9d38a0..bf5702b4acf3e 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx @@ -6,19 +6,41 @@ */ import React, { useMemo } from 'react'; -import { SPAN_METADATA_SECTIONS } from './sections'; import { Span } from '../../../../../typings/es_schemas/ui/span'; -import { getSectionsWithRows } from '../helper'; +import { getSectionsFromFields } from '../helper'; import { MetadataTable } from '..'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { ProcessorEvent } from '../../../../../common/processor_event'; interface Props { span: Span; } export function SpanMetadata({ span }: Props) { - const sectionsWithRows = useMemo( - () => getSectionsWithRows(SPAN_METADATA_SECTIONS, span), - [span] + const { data: spanEvent, status } = useFetcher( + (callApmApi) => { + return callApmApi({ + endpoint: 'GET /api/apm/event_metadata/{processorEvent}/{id}', + params: { + path: { + processorEvent: ProcessorEvent.span, + id: span.span.id, + }, + }, + }); + }, + [span.span.id] + ); + + const sections = useMemo( + () => getSectionsFromFields(spanEvent?.metadata || {}), + [spanEvent?.metadata] + ); + + return ( + <MetadataTable + sections={sections} + isLoading={status === FETCH_STATUS.LOADING} + /> ); - return <MetadataTable sections={sectionsWithRows} />; } diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/sections.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/sections.ts deleted file mode 100644 index f19aef8e0bd8a..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/sections.ts +++ /dev/null @@ -1,29 +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 { - Section, - AGENT, - SERVICE, - SPAN, - LABELS, - EVENT, - TRANSACTION, - TRACE, - MESSAGE_SPAN, -} from '../sections'; - -export const SPAN_METADATA_SECTIONS: Section[] = [ - LABELS, - TRACE, - TRANSACTION, - EVENT, - SPAN, - SERVICE, - MESSAGE_SPAN, - AGENT, -]; diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/TransactionMetadata.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/TransactionMetadata.test.tsx deleted file mode 100644 index 08253f04777d9..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/TransactionMetadata.test.tsx +++ /dev/null @@ -1,164 +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 { render } from '@testing-library/react'; -import React, { ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { TransactionMetadata } from '.'; -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; -import { - expectTextsInDocument, - expectTextsNotInDocument, -} from '../../../../utils/testHelpers'; - -function Wrapper({ children }: { children?: ReactNode }) { - return ( - <MemoryRouter> - <MockApmPluginContextWrapper>{children}</MockApmPluginContextWrapper> - </MemoryRouter> - ); -} - -const renderOptions = { - wrapper: Wrapper, -}; - -function getTransaction() { - return { - labels: { someKey: 'labels value' }, - http: { someKey: 'http value' }, - host: { someKey: 'host value' }, - container: { someKey: 'container value' }, - service: { someKey: 'service value' }, - process: { someKey: 'process value' }, - agent: { someKey: 'agent value' }, - url: { someKey: 'url value' }, - user: { someKey: 'user value' }, - notIncluded: 'not included value', - transaction: { - id: '7efbc7056b746fcb', - notIncluded: 'transaction not included value', - custom: { - someKey: 'custom value', - }, - message: { - age: { ms: 1577958057123 }, - queue: { name: 'queue name' }, - }, - }, - } as unknown as Transaction; -} - -describe('TransactionMetadata', () => { - it('should render a transaction with all sections', () => { - const transaction = getTransaction(); - const output = render( - <TransactionMetadata transaction={transaction} />, - renderOptions - ); - - // sections - expectTextsInDocument(output, [ - 'Labels', - 'HTTP', - 'Host', - 'Container', - 'Service', - 'Process', - 'Agent', - 'URL', - 'User', - 'Custom', - 'Message', - ]); - }); - - it('should render a transaction with all included dot notation keys', () => { - const transaction = getTransaction(); - const output = render( - <TransactionMetadata transaction={transaction} />, - renderOptions - ); - - // included keys - expectTextsInDocument(output, [ - 'labels.someKey', - 'http.someKey', - 'host.someKey', - 'container.someKey', - 'service.someKey', - 'process.someKey', - 'agent.someKey', - 'url.someKey', - 'user.someKey', - 'transaction.custom.someKey', - 'transaction.message.age.ms', - 'transaction.message.queue.name', - ]); - - // excluded keys - expectTextsNotInDocument(output, [ - 'notIncluded', - 'transaction.notIncluded', - ]); - }); - - it('should render a transaction with all included values', () => { - const transaction = getTransaction(); - const output = render( - <TransactionMetadata transaction={transaction} />, - renderOptions - ); - - // included values - expectTextsInDocument(output, [ - 'labels value', - 'http value', - 'host value', - 'container value', - 'service value', - 'process value', - 'agent value', - 'url value', - 'user value', - 'custom value', - '1577958057123', - 'queue name', - ]); - - // excluded values - expectTextsNotInDocument(output, [ - 'not included value', - 'transaction not included value', - ]); - }); - - it('should render a transaction with only the required sections', () => { - const transaction = {} as Transaction; - const output = render( - <TransactionMetadata transaction={transaction} />, - renderOptions - ); - - // required sections should be found - expectTextsInDocument(output, ['Labels', 'User']); - - // optional sections should NOT be found - expectTextsNotInDocument(output, [ - 'HTTP', - 'Host', - 'Container', - 'Service', - 'Process', - 'Agent', - 'URL', - 'Custom', - 'Message', - ]); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx index b3a53472f0815..32c0101c73b4d 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx @@ -6,19 +6,40 @@ */ import React, { useMemo } from 'react'; -import { TRANSACTION_METADATA_SECTIONS } from './sections'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { getSectionsWithRows } from '../helper'; +import { getSectionsFromFields } from '../helper'; import { MetadataTable } from '..'; +import { ProcessorEvent } from '../../../../../common/processor_event'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; interface Props { transaction: Transaction; } export function TransactionMetadata({ transaction }: Props) { - const sectionsWithRows = useMemo( - () => getSectionsWithRows(TRANSACTION_METADATA_SECTIONS, transaction), - [transaction] + const { data: transactionEvent, status } = useFetcher( + (callApmApi) => { + return callApmApi({ + endpoint: 'GET /api/apm/event_metadata/{processorEvent}/{id}', + params: { + path: { + processorEvent: ProcessorEvent.transaction, + id: transaction.transaction.id, + }, + }, + }); + }, + [transaction.transaction.id] + ); + + const sections = useMemo( + () => getSectionsFromFields(transactionEvent?.metadata || {}), + [transactionEvent?.metadata] + ); + return ( + <MetadataTable + sections={sections} + isLoading={status === FETCH_STATUS.LOADING} + /> ); - return <MetadataTable sections={sectionsWithRows} />; } diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/sections.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/sections.ts deleted file mode 100644 index 2f4a3d3229857..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/sections.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 { - Section, - TRANSACTION, - LABELS, - EVENT, - HTTP, - HOST, - CLIENT, - CONTAINER, - SERVICE, - PROCESS, - AGENT, - URL, - PAGE, - USER, - USER_AGENT, - CUSTOM_TRANSACTION, - MESSAGE_TRANSACTION, - TRACE, -} from '../sections'; - -export const TRANSACTION_METADATA_SECTIONS: Section[] = [ - { ...LABELS, required: true }, - TRACE, - TRANSACTION, - EVENT, - HTTP, - HOST, - CLIENT, - CONTAINER, - SERVICE, - PROCESS, - MESSAGE_TRANSACTION, - AGENT, - URL, - { ...PAGE, key: 'transaction.page' }, - { ...USER, required: true }, - USER_AGENT, - CUSTOM_TRANSACTION, -]; diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.test.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.test.ts index 770b35e7d17f2..2e64c170437d8 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.test.ts +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.test.ts @@ -5,62 +5,52 @@ * 2.0. */ -import { getSectionsWithRows, filterSectionsByTerm } from './helper'; -import { LABELS, HTTP, SERVICE } from './sections'; -import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { filterSectionsByTerm, getSectionsFromFields } from './helper'; describe('MetadataTable Helper', () => { - const sections = [ - { ...LABELS, required: true }, - HTTP, - { ...SERVICE, properties: ['environment'] }, - ]; - const apmDoc = { - http: { - headers: { - Connection: 'close', - Host: 'opbeans:3000', - request: { method: 'get' }, - }, - }, - service: { - framework: { name: 'express' }, - environment: 'production', - }, - } as unknown as Transaction; - const metadataItems = getSectionsWithRows(sections, apmDoc); + const fields = { + 'http.headers.Connection': ['close'], + 'http.headers.Host': ['opbeans:3000'], + 'http.headers.request.method': ['get'], + 'service.framework.name': ['express'], + 'service.environment': ['production'], + }; + + const metadataItems = getSectionsFromFields(fields); - it('returns flattened data and required section', () => { + it('returns flattened data', () => { expect(metadataItems).toEqual([ - { key: 'labels', label: 'Labels', required: true, rows: [] }, { key: 'http', - label: 'HTTP', - rows: [ - { key: 'http.headers.Connection', value: 'close' }, - { key: 'http.headers.Host', value: 'opbeans:3000' }, - { key: 'http.headers.request.method', value: 'get' }, + label: 'http', + properties: [ + { field: 'http.headers.Connection', value: ['close'] }, + { field: 'http.headers.Host', value: ['opbeans:3000'] }, + { field: 'http.headers.request.method', value: ['get'] }, ], }, { key: 'service', - label: 'Service', - properties: ['environment'], - rows: [{ key: 'service.environment', value: 'production' }], + label: 'service', + properties: [ + { field: 'service.environment', value: ['production'] }, + { field: 'service.framework.name', value: ['express'] }, + ], }, ]); }); + describe('filter', () => { it('items by key', () => { const filteredItems = filterSectionsByTerm(metadataItems, 'http'); expect(filteredItems).toEqual([ { key: 'http', - label: 'HTTP', - rows: [ - { key: 'http.headers.Connection', value: 'close' }, - { key: 'http.headers.Host', value: 'opbeans:3000' }, - { key: 'http.headers.request.method', value: 'get' }, + label: 'http', + properties: [ + { field: 'http.headers.Connection', value: ['close'] }, + { field: 'http.headers.Host', value: ['opbeans:3000'] }, + { field: 'http.headers.request.method', value: ['get'] }, ], }, ]); @@ -71,9 +61,8 @@ describe('MetadataTable Helper', () => { expect(filteredItems).toEqual([ { key: 'service', - label: 'Service', - properties: ['environment'], - rows: [{ key: 'service.environment', value: 'production' }], + label: 'service', + properties: [{ field: 'service.environment', value: ['production'] }], }, ]); }); diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.ts index bd115c1c7c174..c9e0f2aa66745 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.ts +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.ts @@ -5,35 +5,52 @@ * 2.0. */ -import { get, pick, isEmpty } from 'lodash'; -import { Section } from './sections'; -import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; -import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; -import { Span } from '../../../../typings/es_schemas/ui/span'; -import { flattenObject, KeyValuePair } from '../../../utils/flattenObject'; - -export type SectionsWithRows = ReturnType<typeof getSectionsWithRows>; - -export const getSectionsWithRows = ( - sections: Section[], - apmDoc: Transaction | APMError | Span -) => { - return sections - .map((section) => { - const sectionData: Record<string, unknown> = get(apmDoc, section.key); - const filteredData: Record<string, unknown> | undefined = - section.properties - ? pick(sectionData, section.properties) - : sectionData; - - const rows: KeyValuePair[] = flattenObject(filteredData, section.key); - return { ...section, rows }; - }) - .filter(({ required, rows }) => required || !isEmpty(rows)); +import { isEmpty, groupBy, partition } from 'lodash'; +import type { SectionDescriptor } from './types'; + +const EXCLUDED_FIELDS = ['error.exception.stacktrace', 'span.stacktrace']; + +export const getSectionsFromFields = (fields: Record<string, any>) => { + const rows = Object.keys(fields) + .filter( + (field) => !EXCLUDED_FIELDS.some((excluded) => field.startsWith(excluded)) + ) + .sort() + .map((field) => { + return { + section: field.split('.')[0], + field, + value: fields[field], + }; + }); + + const sections = Object.values(groupBy(rows, 'section')).map( + (rowsForSection) => { + const first = rowsForSection[0]; + + const section: SectionDescriptor = { + key: first.section, + label: first.section.toLowerCase(), + properties: rowsForSection.map((row) => ({ + field: row.field, + value: row.value, + })), + }; + + return section; + } + ); + + const [labelSections, otherSections] = partition( + sections, + (section) => section.key === 'labels' + ); + + return [...labelSections, ...otherSections]; }; export const filterSectionsByTerm = ( - sections: SectionsWithRows, + sections: SectionDescriptor[], searchTerm: string ) => { if (!searchTerm) { @@ -41,15 +58,16 @@ export const filterSectionsByTerm = ( } return sections .map((section) => { - const { rows = [] } = section; - const filteredRows = rows.filter(({ key, value }) => { - const valueAsString = String(value).toLowerCase(); + const { properties = [] } = section; + const filteredProps = properties.filter(({ field, value }) => { return ( - key.toLowerCase().includes(searchTerm) || - valueAsString.includes(searchTerm) + field.toLowerCase().includes(searchTerm) || + value.some((val: string | number) => + String(val).toLowerCase().includes(searchTerm) + ) ); }); - return { ...section, rows: filteredRows }; + return { ...section, properties: filteredProps }; }) - .filter(({ rows }) => !isEmpty(rows)); + .filter(({ properties }) => !isEmpty(properties)); }; diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx index 45be525512d0a..248fa240fd557 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx @@ -19,18 +19,21 @@ import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import React, { useCallback } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { HeightRetainer } from '../HeightRetainer'; import { fromQuery, toQuery } from '../Links/url_helpers'; -import { filterSectionsByTerm, SectionsWithRows } from './helper'; +import { filterSectionsByTerm } from './helper'; import { Section } from './Section'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { SectionDescriptor } from './types'; interface Props { - sections: SectionsWithRows; + sections: SectionDescriptor[]; + isLoading: boolean; } -export function MetadataTable({ sections }: Props) { +export function MetadataTable({ sections, isLoading }: Props) { const history = useHistory(); const location = useLocation(); const { urlParams } = useUrlParams(); @@ -77,6 +80,13 @@ export function MetadataTable({ sections }: Props) { /> </EuiFlexItem> </EuiFlexGroup> + {isLoading && ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiLoadingSpinner /> + </EuiFlexItem> + </EuiFlexGroup> + )} <HeightRetainer> {filteredSections.map((section) => ( <div key={section.key}> @@ -84,7 +94,7 @@ export function MetadataTable({ sections }: Props) { <h6>{section.label}</h6> </EuiTitle> <EuiSpacer size="s" /> - <Section keyValuePairs={section.rows} /> + <Section properties={section.properties} /> <EuiSpacer size="xl" /> </div> ))} diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/sections.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/sections.ts deleted file mode 100644 index efc2ef8bde66b..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/sections.ts +++ /dev/null @@ -1,173 +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 { i18n } from '@kbn/i18n'; - -export interface Section { - key: string; - label: string; - required?: boolean; - properties?: string[]; -} - -export const LABELS: Section = { - key: 'labels', - label: i18n.translate('xpack.apm.metadataTable.section.labelsLabel', { - defaultMessage: 'Labels', - }), -}; - -export const EVENT: Section = { - key: 'event', - label: i18n.translate('xpack.apm.metadataTable.section.eventLabel', { - defaultMessage: 'event', - }), - properties: ['outcome'], -}; - -export const HTTP: Section = { - key: 'http', - label: i18n.translate('xpack.apm.metadataTable.section.httpLabel', { - defaultMessage: 'HTTP', - }), -}; - -export const HOST: Section = { - key: 'host', - label: i18n.translate('xpack.apm.metadataTable.section.hostLabel', { - defaultMessage: 'Host', - }), -}; - -export const CLIENT: Section = { - key: 'client', - label: i18n.translate('xpack.apm.metadataTable.section.clientLabel', { - defaultMessage: 'Client', - }), - properties: ['ip'], -}; - -export const CONTAINER: Section = { - key: 'container', - label: i18n.translate('xpack.apm.metadataTable.section.containerLabel', { - defaultMessage: 'Container', - }), -}; - -export const SERVICE: Section = { - key: 'service', - label: i18n.translate('xpack.apm.metadataTable.section.serviceLabel', { - defaultMessage: 'Service', - }), -}; - -export const PROCESS: Section = { - key: 'process', - label: i18n.translate('xpack.apm.metadataTable.section.processLabel', { - defaultMessage: 'Process', - }), -}; - -export const AGENT: Section = { - key: 'agent', - label: i18n.translate('xpack.apm.metadataTable.section.agentLabel', { - defaultMessage: 'Agent', - }), -}; - -export const URL: Section = { - key: 'url', - label: i18n.translate('xpack.apm.metadataTable.section.urlLabel', { - defaultMessage: 'URL', - }), -}; - -export const USER: Section = { - key: 'user', - label: i18n.translate('xpack.apm.metadataTable.section.userLabel', { - defaultMessage: 'User', - }), -}; - -export const USER_AGENT: Section = { - key: 'user_agent', - label: i18n.translate('xpack.apm.metadataTable.section.userAgentLabel', { - defaultMessage: 'User agent', - }), -}; - -export const PAGE: Section = { - key: 'page', - label: i18n.translate('xpack.apm.metadataTable.section.pageLabel', { - defaultMessage: 'Page', - }), -}; - -export const SPAN: Section = { - key: 'span', - label: i18n.translate('xpack.apm.metadataTable.section.spanLabel', { - defaultMessage: 'Span', - }), - properties: ['id'], -}; - -export const TRANSACTION: Section = { - key: 'transaction', - label: i18n.translate('xpack.apm.metadataTable.section.transactionLabel', { - defaultMessage: 'Transaction', - }), - properties: ['id'], -}; - -export const TRACE: Section = { - key: 'trace', - label: i18n.translate('xpack.apm.metadataTable.section.traceLabel', { - defaultMessage: 'Trace', - }), - properties: ['id'], -}; - -export const ERROR: Section = { - key: 'error', - label: i18n.translate('xpack.apm.metadataTable.section.errorLabel', { - defaultMessage: 'Error', - }), - properties: ['id'], -}; - -const customLabel = i18n.translate( - 'xpack.apm.metadataTable.section.customLabel', - { - defaultMessage: 'Custom', - } -); - -export const CUSTOM_ERROR: Section = { - key: 'error.custom', - label: customLabel, -}; -export const CUSTOM_TRANSACTION: Section = { - key: 'transaction.custom', - label: customLabel, -}; - -const messageLabel = i18n.translate( - 'xpack.apm.metadataTable.section.messageLabel', - { - defaultMessage: 'Message', - } -); - -export const MESSAGE_TRANSACTION: Section = { - key: 'transaction.message', - label: messageLabel, -}; - -export const MESSAGE_SPAN: Section = { - key: 'span.message', - label: messageLabel, -}; diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/types.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/types.ts new file mode 100644 index 0000000000000..3ce7698460f30 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/types.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 interface SectionDescriptor { + key: string; + label: string; + required?: boolean; + properties: Array<{ field: string; value: string[] | number[] }>; +} diff --git a/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx b/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx new file mode 100644 index 0000000000000..368125d7a6fd6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ReactElement } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useApmRouter } from '../../../hooks/use_apm_router'; +import { useDateRangeRedirect } from '../../../hooks/use_date_range_redirect'; + +// This is a top-level component that blocks rendering of the routes +// if there is no valid date range, and redirects to one if needed. +// If we don't do this, routes down the tree will fail because they +// expect the rangeFrom/rangeTo parameters to be set in the URL. +// +// This should be considered a temporary workaround until we have a +// more comprehensive solution for redirects that require context. + +export function RedirectWithDefaultDateRange({ + children, +}: { + children: ReactElement; +}) { + const { isDateRangeSet, redirect } = useDateRangeRedirect(); + + const apmRouter = useApmRouter(); + const location = useLocation(); + + const matchingRoutes = apmRouter.getRoutesToMatch(location.pathname); + + if ( + !isDateRangeSet && + matchingRoutes.some((route) => { + return ( + route.path === '/services' || + route.path === '/traces' || + route.path === '/service-map' || + route.path === '/backends' || + route.path === '/services/{serviceName}' || + location.pathname === '/' || + location.pathname === '' + ); + }) + ) { + redirect(); + return null; + } + + return children; +} diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx index 58e0e7465925a..db30e73c86dc7 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx @@ -75,6 +75,8 @@ describe('when transactionType is selected and multiple transaction types are gi serviceTransactionTypes: ['firstType', 'secondType'], urlParams: { transactionType: 'secondType', + rangeFrom: 'now-15m', + rangeTo: 'now', }, }); @@ -95,6 +97,8 @@ describe('when transactionType is selected and multiple transaction types are gi serviceTransactionTypes: ['firstType', 'secondType'], urlParams: { transactionType: 'secondType', + rangeFrom: 'now-15m', + rangeTo: 'now', }, }); diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index 035635908e56c..5f5a25393c7d1 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -13,6 +13,9 @@ import { EuiSpacer, } from '@elastic/eui'; import React from 'react'; +import { useTimeRangeId } from '../../context/time_range_id/use_time_range_id'; +import { toBoolean, toNumber } from '../../context/url_params_context/helpers'; +import { useApmParams } from '../../hooks/use_apm_params'; import { useBreakpoints } from '../../hooks/use_breakpoints'; import { DatePicker } from './DatePicker'; import { KueryBar } from './kuery_bar'; @@ -28,6 +31,39 @@ interface Props { kueryBarBoolFilter?: QueryDslQueryContainer[]; } +function ApmDatePicker() { + const { query } = useApmParams('/*'); + + if (!('rangeFrom' in query)) { + throw new Error('range not available in route parameters'); + } + + const { + rangeFrom, + rangeTo, + refreshPaused: refreshPausedFromUrl = 'true', + refreshInterval: refreshIntervalFromUrl = '0', + } = query; + + const refreshPaused = toBoolean(refreshPausedFromUrl); + + const refreshInterval = toNumber(refreshIntervalFromUrl); + + const { incrementTimeRangeId } = useTimeRangeId(); + + return ( + <DatePicker + rangeFrom={rangeFrom} + rangeTo={rangeTo} + refreshPaused={refreshPaused} + refreshInterval={refreshInterval} + onTimeRangeRefresh={() => { + incrementTimeRangeId(); + }} + /> + ); +} + export function SearchBar({ hidden = false, showKueryBar = true, @@ -87,7 +123,7 @@ export function SearchBar({ </EuiFlexItem> )} <EuiFlexItem grow={false}> - <DatePicker /> + <ApmDatePicker /> </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> 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 eb0087d180146..617af6dae484d 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 @@ -10,6 +10,7 @@ import { Observable, of } from 'rxjs'; import { RouterProvider } from '@kbn/typed-react-router-config'; import { useHistory } from 'react-router-dom'; import { createMemoryHistory, History } from 'history'; +import { merge } from 'lodash'; import { UrlService } from '../../../../../../src/plugins/share/common/url_service'; import { createObservabilityRuleTypeRegistryMock } from '../../../../observability/public'; import { ApmPluginContext, ApmPluginContextValue } from './apm_plugin_context'; @@ -138,25 +139,28 @@ export function MockApmPluginContextWrapper({ value?: ApmPluginContextValue; history?: History; }) { - if (value.core) { - createCallApmApi(value.core); + const contextValue = merge({}, mockApmPluginContextValue, value); + + if (contextValue.core) { + createCallApmApi(contextValue.core); } const contextHistory = useHistory(); const usedHistory = useMemo(() => { - return history || contextHistory || createMemoryHistory(); + return ( + history || + contextHistory || + createMemoryHistory({ + initialEntries: ['/services/?rangeFrom=now-15m&rangeTo=now'], + }) + ); }, [history, contextHistory]); return ( - <RouterProvider router={apmRouter as any} history={usedHistory}> - <ApmPluginContext.Provider - value={{ - ...mockApmPluginContextValue, - ...value, - }} - > + <ApmPluginContext.Provider value={contextValue}> + <RouterProvider router={apmRouter as any} history={usedHistory}> {children} - </ApmPluginContext.Provider> - </RouterProvider> + </RouterProvider> + </ApmPluginContext.Provider> ); } diff --git a/x-pack/plugins/apm/public/hooks/use_date_range_redirect.ts b/x-pack/plugins/apm/public/hooks/use_date_range_redirect.ts new file mode 100644 index 0000000000000..0446b35872045 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_date_range_redirect.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import qs from 'query-string'; +import { useHistory, useLocation } from 'react-router-dom'; +import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; +import { TimePickerTimeDefaults } from '../components/shared/DatePicker/typings'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; + +export function useDateRangeRedirect() { + const history = useHistory(); + const location = useLocation(); + const query = qs.parse(location.search); + + const { core, plugins } = useApmPluginContext(); + + const timePickerTimeDefaults = core.uiSettings.get<TimePickerTimeDefaults>( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + const timePickerSharedState = + plugins.data.query.timefilter.timefilter.getTime(); + + const isDateRangeSet = 'rangeFrom' in query && 'rangeTo' in query; + + const redirect = () => { + const nextQuery = { + rangeFrom: timePickerSharedState.from ?? timePickerTimeDefaults.from, + rangeTo: timePickerSharedState.to ?? timePickerTimeDefaults.to, + ...query, + }; + + history.replace({ + ...location, + search: qs.stringify(nextQuery), + }); + }; + + return { + isDateRangeSet, + redirect, + }; +} diff --git a/x-pack/plugins/apm/public/hooks/use_time_range.ts b/x-pack/plugins/apm/public/hooks/use_time_range.ts index 5c6a78ba5c46a..79ca70130a442 100644 --- a/x-pack/plugins/apm/public/hooks/use_time_range.ts +++ b/x-pack/plugins/apm/public/hooks/use_time_range.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useRef } from 'react'; +import { useMemo } from 'react'; import { useTimeRangeId } from '../context/time_range_id/use_time_range_id'; import { getDateRange } from '../context/url_params_context/helpers'; @@ -41,29 +41,16 @@ export function useTimeRange({ rangeTo?: string; optional?: boolean; }): TimeRange | PartialTimeRange { - const rangeRef = useRef({ rangeFrom, rangeTo }); + const { incrementTimeRangeId, timeRangeId } = useTimeRangeId(); - const { timeRangeId, incrementTimeRangeId } = useTimeRangeId(); - - const timeRangeIdRef = useRef(timeRangeId); - - const stateRef = useRef(getDateRange({ state: {}, rangeFrom, rangeTo })); - - const updateParsedTime = () => { - stateRef.current = getDateRange({ state: {}, rangeFrom, rangeTo }); - }; - - if ( - timeRangeIdRef.current !== timeRangeId || - rangeRef.current.rangeFrom !== rangeFrom || - rangeRef.current.rangeTo !== rangeTo - ) { - updateParsedTime(); - } - - rangeRef.current = { rangeFrom, rangeTo }; - - const { start, end, exactStart, exactEnd } = stateRef.current; + const { start, end, exactStart, exactEnd } = useMemo(() => { + return getDateRange({ + state: {}, + rangeFrom, + rangeTo, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rangeFrom, rangeTo, timeRangeId]); if ((!start || !end || !exactStart || !exactEnd) && !optional) { throw new Error('start and/or end were unexpectedly not set'); diff --git a/x-pack/plugins/apm/public/tutorial/tutorial_fleet_instructions/index.tsx b/x-pack/plugins/apm/public/tutorial/tutorial_fleet_instructions/index.tsx index c69623f92987a..175d6797bb29f 100644 --- a/x-pack/plugins/apm/public/tutorial/tutorial_fleet_instructions/index.tsx +++ b/x-pack/plugins/apm/public/tutorial/tutorial_fleet_instructions/index.tsx @@ -86,7 +86,7 @@ function TutorialFleetInstructions({ http, basePath, isDarkTheme }: Props) { 'xpack.apm.tutorial.apmServer.fleet.message', { defaultMessage: - 'The APM integration installs Elasticsearch templates and Ingest Node pipelines for APM data.', + 'The APM integration installs Elasticsearch templates and ingest pipelines for APM data.', } )} footer={ diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json index 31f756eeabde3..8254ec67eb3c5 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json @@ -7,7 +7,10 @@ ], "exclude": [ "**/__fixtures__/**/*", - "./x-pack/plugins/apm/ftr_e2e" + "./x-pack/plugins/apm/ftr_e2e", + "./x-pack/plugins/apm/e2e", + "**/target/**", + "**/node_modules/**" ], "compilerOptions": { "noErrorTruncation": true diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index 0d1ed4745d00d..4c1fe784ea490 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -6,7 +6,6 @@ */ import { i18n } from '@kbn/i18n'; -import { SubFeaturePrivilegeGroupType } from '../../features/common'; import { LicenseType } from '../../licensing/common/types'; import { AlertType, APM_SERVER_FEATURE_ID } from '../common/alert_types'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -39,6 +38,9 @@ export const APM_FEATURE = { read: [], }, alerting: { + alert: { + all: Object.values(AlertType), + }, rule: { all: Object.values(AlertType), }, @@ -57,6 +59,9 @@ export const APM_FEATURE = { read: [], }, alerting: { + alert: { + read: Object.values(AlertType), + }, rule: { read: Object.values(AlertType), }, @@ -67,60 +72,6 @@ export const APM_FEATURE = { ui: ['show', 'alerting:show'], }, }, - subFeatures: [ - { - name: i18n.translate('xpack.apm.featureRegistry.manageAlertsName', { - defaultMessage: 'Alerts', - }), - privilegeGroups: [ - { - groupType: 'mutually_exclusive' as SubFeaturePrivilegeGroupType, - privileges: [ - { - id: 'alerts_all', - name: i18n.translate( - 'xpack.apm.featureRegistry.subfeature.alertsAllName', - { - defaultMessage: 'All', - } - ), - includeIn: 'all' as 'all', - alerting: { - alert: { - all: Object.values(AlertType), - }, - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - { - id: 'alerts_read', - name: i18n.translate( - 'xpack.apm.featureRegistry.subfeature.alertsReadName', - { - defaultMessage: 'Read', - } - ), - includeIn: 'read' as 'read', - alerting: { - alert: { - read: Object.values(AlertType), - }, - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - ], - }, - ], - }, - ], }; interface Feature { diff --git a/x-pack/plugins/apm/server/lib/event_metadata/get_event_metadata.ts b/x-pack/plugins/apm/server/lib/event_metadata/get_event_metadata.ts new file mode 100644 index 0000000000000..97e2e1356363f --- /dev/null +++ b/x-pack/plugins/apm/server/lib/event_metadata/get_event_metadata.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; +import { + ERROR_ID, + SPAN_ID, + TRANSACTION_ID, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import type { APMEventClient } from '../helpers/create_es_client/create_apm_event_client'; + +export async function getEventMetadata({ + apmEventClient, + processorEvent, + id, +}: { + apmEventClient: APMEventClient; + processorEvent: ProcessorEvent; + id: string; +}) { + const filter: QueryDslQueryContainer[] = []; + + switch (processorEvent) { + case ProcessorEvent.error: + filter.push({ + term: { [ERROR_ID]: id }, + }); + break; + + case ProcessorEvent.transaction: + filter.push({ + term: { + [TRANSACTION_ID]: id, + }, + }); + break; + + case ProcessorEvent.span: + filter.push({ + term: { [SPAN_ID]: id }, + }); + break; + } + + const response = await apmEventClient.search('get_event_metadata', { + apm: { + events: [processorEvent], + }, + body: { + query: { + bool: { filter }, + }, + size: 1, + _source: false, + fields: [{ field: '*', include_unmapped: true }], + }, + terminate_after: 1, + }); + + return response.hits.hits[0].fields; +} diff --git a/x-pack/plugins/apm/server/routes/event_metadata.ts b/x-pack/plugins/apm/server/routes/event_metadata.ts new file mode 100644 index 0000000000000..8970ab8ffdeea --- /dev/null +++ b/x-pack/plugins/apm/server/routes/event_metadata.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { createApmServerRoute } from './create_apm_server_route'; +import { getEventMetadata } from '../lib/event_metadata/get_event_metadata'; +import { processorEventRt } from '../../common/processor_event'; +import { setupRequest } from '../lib/helpers/setup_request'; + +const eventMetadataRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/event_metadata/{processorEvent}/{id}', + options: { tags: ['access:apm'] }, + params: t.type({ + path: t.type({ + processorEvent: processorEventRt, + id: t.string, + }), + }), + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { + path: { processorEvent, id }, + } = resources.params; + + const metadata = await getEventMetadata({ + apmEventClient: setup.apmEventClient, + processorEvent, + id, + }); + + return { + metadata, + }; + }, +}); + +export const eventMetadataRouteRepository = + createApmServerRouteRepository().add(eventMetadataRoute); diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts index 7aa520dd5b8a2..472e46fecfa10 100644 --- a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -33,6 +33,7 @@ import { traceRouteRepository } from './traces'; import { transactionRouteRepository } from './transactions'; import { APMRouteHandlerResources } from './typings'; import { historicalDataRouteRepository } from './historical_data'; +import { eventMetadataRouteRepository } from './event_metadata'; import { suggestionsRouteRepository } from './suggestions'; const getTypedGlobalApmServerRouteRepository = () => { @@ -58,7 +59,8 @@ const getTypedGlobalApmServerRouteRepository = () => { .merge(apmFleetRouteRepository) .merge(backendsRouteRepository) .merge(fallbackToTransactionsRouteRepository) - .merge(historicalDataRouteRepository); + .merge(historicalDataRouteRepository) + .merge(eventMetadataRouteRepository); return repository; }; diff --git a/x-pack/plugins/banners/server/config.test.ts b/x-pack/plugins/banners/server/config.test.ts deleted file mode 100644 index f080281cf730d..0000000000000 --- a/x-pack/plugins/banners/server/config.test.ts +++ /dev/null @@ -1,38 +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 { config } from './config'; -import { getDeprecationsFor } from '../../../../src/core/server/test_utils'; - -function applyDeprecations(settings?: Record<string, any>) { - return getDeprecationsFor({ provider: config.deprecations!, settings, path: 'xpack.banners' }); -} - -describe('deprecations', () => { - it('replaces xpack.banners.placement from "header" to "top"', () => { - const { migrated } = applyDeprecations({ - placement: 'header', - }); - expect(migrated.xpack.banners.placement).toBe('top'); - }); - it('logs a warning message about xpack.banners.placement renaming', () => { - const { messages } = applyDeprecations({ - placement: 'header', - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "The \`header\` value for xpack.banners.placement has been replaced by \`top\`", - ] - `); - }); - it('do not rename other placement values', () => { - const { migrated, messages } = applyDeprecations({ - placement: 'disabled', - }); - expect(migrated.xpack.banners.placement).toBe('disabled'); - expect(messages.length).toBe(0); - }); -}); diff --git a/x-pack/plugins/banners/server/config.ts b/x-pack/plugins/banners/server/config.ts index 37b4c57fc2ce1..cc0e18c32e310 100644 --- a/x-pack/plugins/banners/server/config.ts +++ b/x-pack/plugins/banners/server/config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { get } from 'lodash'; +// import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; import { PluginConfigDescriptor } from 'kibana/server'; import { isHexColor } from './utils'; @@ -39,23 +39,4 @@ export type BannersConfigType = TypeOf<typeof configSchema>; export const config: PluginConfigDescriptor<BannersConfigType> = { schema: configSchema, exposeToBrowser: {}, - deprecations: () => [ - (rootConfig, fromPath, addDeprecation) => { - const pluginConfig = get(rootConfig, fromPath); - if (pluginConfig?.placement === 'header') { - addDeprecation({ - message: 'The `header` value for xpack.banners.placement has been replaced by `top`', - correctiveActions: { - manualSteps: [ - `Remove "xpack.banners.placement: header" from your kibana configs.`, - `Add "xpack.banners.placement: to" to your kibana configs instead.`, - ], - }, - }); - return { - set: [{ path: `${fromPath}.placement`, value: 'top' }], - }; - } - }, - ], }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss index ae26a1bee99a6..204de7d4b345d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss @@ -1,6 +1,7 @@ .canvasEmbeddable { .embPanel { border: none; + border-style: none !important; background: none; .embPanel__title { diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 772c030e11539..9c4d1b2179d82 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -28,7 +28,7 @@ "uiActions", "share" ], - "optionalPlugins": ["home", "reporting", "usageCollection"], + "optionalPlugins": ["home", "reporting", "spaces", "usageCollection"], "requiredBundles": [ "discover", "home", diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index f2fe944bfd45d..04d3958b68e36 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -77,7 +77,7 @@ export const renderApp = ({ <presentationUtil.ContextProvider> <I18nProvider> <Provider store={canvasStore}> - <App /> + <App history={params.history} /> </Provider> </I18nProvider> </presentationUtil.ContextProvider> diff --git a/x-pack/plugins/canvas/public/components/app/index.tsx b/x-pack/plugins/canvas/public/components/app/index.tsx index ec9dbd47fd7c7..288ecaf83ab69 100644 --- a/x-pack/plugins/canvas/public/components/app/index.tsx +++ b/x-pack/plugins/canvas/public/components/app/index.tsx @@ -10,6 +10,7 @@ import PropTypes from 'prop-types'; import { History } from 'history'; // @ts-expect-error import createHashStateHistory from 'history-extra/dist/createHashStateHistory'; +import { ScopedHistory } from 'kibana/public'; import { useNavLinkService } from '../../services'; // @ts-expect-error import { shortcutManager } from '../../lib/shortcut_manager'; @@ -29,7 +30,7 @@ class ShortcutManagerContextWrapper extends React.Component { } } -export const App: FC = () => { +export const App: FC<{ history: ScopedHistory }> = ({ history }) => { const historyRef = useRef<History>(createHashStateHistory() as History); const { updatePath } = useNavLinkService(); @@ -39,6 +40,15 @@ export const App: FC = () => { }); }); + // We are using our own history due to needing pushState functionality not yet available on standard history + // This effect will listen for changes on the scoped history and push that to our history + // This is needed for SavedObject.resolve redirects + useEffect(() => { + return history.listen((location) => { + historyRef.current.replace(location.hash.substr(1)); + }); + }, [history]); + return ( <ShortcutManagerContextWrapper> <div className="canvas canvasContainer"> diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss index 15d6b13e3fbf8..a26c264938987 100644 --- a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss @@ -68,4 +68,9 @@ body.canvas-isFullscreen { box-shadow: none; overflow: hidden; } + + // When in fullscreen, we want to make sure to hide the "there was a conflict" resolve callout + .canvasContainer > .euiCallOut { + display: none; + } } diff --git a/x-pack/plugins/canvas/public/components/positionable/positionable.scss b/x-pack/plugins/canvas/public/components/positionable/positionable.scss index d1d927672e052..6dd0d713a0308 100644 --- a/x-pack/plugins/canvas/public/components/positionable/positionable.scss +++ b/x-pack/plugins/canvas/public/components/positionable/positionable.scss @@ -1,4 +1,3 @@ .canvasPositionable { transform-origin: center center; /* the default, only for clarity */ - transform-style: preserve-3d; } diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index bd5d884f1485c..723d1afea2860 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -19,6 +19,7 @@ import { PluginInitializerContext, } from '../../../../src/core/public'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { SpacesPluginStart } from '../../spaces/public'; import { initLoadingIndicator } from './lib/loading_indicator'; import { getSessionStorage } from './lib/storage'; import { SESSIONSTORAGE_LASTPATH, CANVAS_APP } from '../common/lib/constants'; @@ -62,6 +63,7 @@ export interface CanvasStartDeps { charts: ChartsPluginStart; data: DataPublicPluginStart; presentationUtil: PresentationUtilPluginStart; + spaces?: SpacesPluginStart; } /** diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx index 0fd4d3d2401f7..846af8a891434 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx @@ -11,7 +11,8 @@ import { useWorkpad } from './use_workpad'; const mockDispatch = jest.fn(); const mockSelector = jest.fn(); -const mockGetWorkpad = jest.fn(); +const mockResolveWorkpad = jest.fn(); +const mockRedirectLegacyUrl = jest.fn(); const workpad = { id: 'someworkpad', @@ -33,7 +34,10 @@ jest.mock('react-redux', () => ({ jest.mock('../../../services', () => ({ useWorkpadService: () => ({ - get: mockGetWorkpad, + resolve: mockResolveWorkpad, + }), + usePlatformService: () => ({ + redirectLegacyUrl: mockRedirectLegacyUrl, }), })); @@ -51,15 +55,59 @@ describe('useWorkpad', () => { test('fires request to load workpad and dispatches results', async () => { const workpadId = 'someworkpad'; - mockGetWorkpad.mockResolvedValue(workpadResponse); + const getRedirectPath = (id: string) => id; + mockResolveWorkpad.mockResolvedValue({ + outcome: 'exactMatch', + workpad: workpadResponse, + }); - renderHook(() => useWorkpad(workpadId)); + renderHook(() => useWorkpad(workpadId, true, getRedirectPath)); - await waitFor(() => expect(mockGetWorkpad).toHaveBeenCalledWith(workpadId)); + await waitFor(() => expect(mockResolveWorkpad).toHaveBeenCalledWith(workpadId)); - expect(mockGetWorkpad).toHaveBeenCalledWith(workpadId); + expect(mockResolveWorkpad).toHaveBeenCalledWith(workpadId); expect(mockDispatch).toHaveBeenCalledWith({ type: 'setAssets', payload: assets }); expect(mockDispatch).toHaveBeenCalledWith({ type: 'setWorkpad', payload: workpad }); expect(mockDispatch).toHaveBeenCalledWith({ type: 'setZoomScale', payload: 1 }); }); + + test('sets alias id of workpad on a conflict', async () => { + const workpadId = 'someworkpad'; + const getRedirectPath = (id: string) => id; + const aliasId = 'someworkpad-alias'; + mockResolveWorkpad.mockResolvedValue({ + outcome: 'conflict', + workpad: workpadResponse, + aliasId, + }); + + renderHook(() => useWorkpad(workpadId, true, getRedirectPath)); + + await waitFor(() => expect(mockResolveWorkpad).toHaveBeenCalledWith(workpadId)); + + expect(mockResolveWorkpad).toHaveBeenCalledWith(workpadId); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setAssets', payload: assets }); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'setWorkpad', + payload: { ...workpad, aliasId }, + }); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setZoomScale', payload: 1 }); + }); + + test('redirects on alias match', async () => { + const workpadId = 'someworkpad'; + const getRedirectPath = (id: string) => id; + const aliasId = 'someworkpad-alias'; + mockResolveWorkpad.mockResolvedValue({ + outcome: 'aliasMatch', + workpad: workpadResponse, + aliasId, + }); + + renderHook(() => useWorkpad(workpadId, true, getRedirectPath)); + + await waitFor(() => expect(mockResolveWorkpad).toHaveBeenCalledWith(workpadId)); + + expect(mockRedirectLegacyUrl).toBeCalledWith(`#${aliasId}`, 'Workpad'); + }); }); diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts index 983622dad264d..f8ddd769aac43 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts @@ -6,8 +6,9 @@ */ import { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; import { useDispatch, useSelector } from 'react-redux'; -import { useWorkpadService } from '../../../services'; +import { useWorkpadService, usePlatformService } from '../../../services'; import { getWorkpad } from '../../../state/selectors/workpad'; import { setWorkpad } from '../../../state/actions/workpad'; // @ts-expect-error @@ -16,11 +17,18 @@ import { setAssets } from '../../../state/actions/assets'; import { setZoomScale } from '../../../state/actions/transient'; import { CanvasWorkpad } from '../../../../types'; +const getWorkpadLabel = () => + i18n.translate('xpack.canvas.workpadResolve.redirectLabel', { + defaultMessage: 'Workpad', + }); + export const useWorkpad = ( workpadId: string, - loadPages: boolean = true + loadPages: boolean = true, + getRedirectPath: (workpadId: string) => string ): [CanvasWorkpad | undefined, string | Error | undefined] => { const workpadService = useWorkpadService(); + const platformService = usePlatformService(); const dispatch = useDispatch(); const storedWorkpad = useSelector(getWorkpad); const [error, setError] = useState<string | Error | undefined>(undefined); @@ -28,15 +36,28 @@ export const useWorkpad = ( useEffect(() => { (async () => { try { - const { assets, ...workpad } = await workpadService.get(workpadId); + const { + outcome, + aliasId, + workpad: { assets, ...workpad }, + } = await workpadService.resolve(workpadId); + + if (outcome === 'conflict') { + workpad.aliasId = aliasId; + } + dispatch(setAssets(assets)); dispatch(setWorkpad(workpad, { loadPages })); dispatch(setZoomScale(1)); + + if (outcome === 'aliasMatch' && platformService.redirectLegacyUrl && aliasId) { + platformService.redirectLegacyUrl(`#${getRedirectPath(aliasId)}`, getWorkpadLabel()); + } } catch (e) { - setError(e); + setError(e as Error | string); } })(); - }, [workpadId, dispatch, setError, loadPages, workpadService]); + }, [workpadId, dispatch, setError, loadPages, workpadService, getRedirectPath, platformService]); return [storedWorkpad.id === workpadId ? storedWorkpad : undefined, error]; }; diff --git a/x-pack/plugins/canvas/public/routes/workpad/index.tsx b/x-pack/plugins/canvas/public/routes/workpad/index.tsx index 4c98511baad0b..0b6153bc06afd 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/index.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/index.tsx @@ -13,6 +13,7 @@ export { WorkpadRoutingContext, WorkpadRoutingContextType } from './workpad_rout export interface WorkpadRouteParams { id: string; + pageNumber?: string; } export interface WorkpadPageRouteParams extends WorkpadRouteParams { diff --git a/x-pack/plugins/canvas/public/routes/workpad/workpad_presentation_helper.tsx b/x-pack/plugins/canvas/public/routes/workpad/workpad_presentation_helper.tsx index bdf84de7a47bd..084c9d8c76b00 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/workpad_presentation_helper.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/workpad_presentation_helper.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { i18n } from '@kbn/i18n'; import React, { FC, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { getBaseBreadcrumb, getWorkpadBreadcrumb } from '../../lib/breadcrumbs'; @@ -16,6 +16,11 @@ import { useAutoplayHelper } from './hooks/use_autoplay_helper'; import { useRefreshHelper } from './hooks/use_refresh_helper'; import { usePlatformService } from '../../services'; +const getWorkpadLabel = () => + i18n.translate('xpack.canvas.workpadConflict.redirectLabel', { + defaultMessage: 'Workpad', + }); + export const WorkpadPresentationHelper: FC = ({ children }) => { const platformService = usePlatformService(); const workpad = useSelector(getWorkpad); @@ -34,5 +39,19 @@ export const WorkpadPresentationHelper: FC = ({ children }) => { setDocTitle(workpad.name); }, [workpad.name]); - return <>{children}</>; + const conflictElement = workpad.aliasId + ? platformService.getLegacyUrlConflict?.({ + objectNoun: getWorkpadLabel(), + currentObjectId: workpad.id, + otherObjectId: workpad.aliasId, + otherObjectPath: `#/workpad/${workpad.aliasId}`, + }) + : null; + + return ( + <> + {conflictElement} + {children} + </> + ); }; diff --git a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx index 2c1ad4fcb6aa1..c5374c3ad6e04 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, useEffect } from 'react'; +import React, { FC, useEffect, useCallback } from 'react'; import { Route, Switch, Redirect, useParams } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { WorkpadApp } from '../../components/workpad_app'; @@ -21,56 +21,93 @@ import { useRestoreHistory } from './hooks/use_restore_history'; import { useWorkpadHistory } from './hooks/use_workpad_history'; import { usePageSync } from './hooks/use_page_sync'; import { useWorkpadPersist } from './hooks/use_workpad_persist'; -import { WorkpadPageRouteProps, WorkpadRouteProps, WorkpadPageRouteParams } from '.'; +import { WorkpadRouteProps, WorkpadPageRouteParams } from '.'; import { WorkpadRoutingContextComponent } from './workpad_routing_context'; import { WorkpadPresentationHelper } from './workpad_presentation_helper'; const { workpadRoutes: strings } = ErrorStrings; -export const WorkpadRoute = () => ( - <Route - path={'/workpad/:id'} - exact={false} - children={(route: WorkpadRouteProps) => ( - <WorkpadLoaderComponent params={route.match.params} key="workpad-loader"> - {(workpad: CanvasWorkpad) => ( - <Switch> - <Route - path="/workpad/:id/page/:pageNumber" - children={(pageRoute) => ( - <WorkpadHistoryManager> - <WorkpadRoutingContextComponent> - <WorkpadPresentationHelper> - <WorkpadApp /> - </WorkpadPresentationHelper> - </WorkpadRoutingContextComponent> - </WorkpadHistoryManager> - )} - /> - <Route path="/workpad/:id" strict={false} exact={true}> - <Redirect to={`/workpad/${route.match.params.id}/page/${workpad.page + 1}`} /> - </Route> - </Switch> - )} - </WorkpadLoaderComponent> - )} - /> -); - -export const ExportWorkpadRoute = () => ( - <Route - path={'/export/workpad/pdf/:id/page/:pageNumber'} - children={(route: WorkpadPageRouteProps) => ( - <WorkpadLoaderComponent loadPages={false} params={route.match.params}> - {() => ( - <ExportRouteManager> - <ExportApp /> - </ExportRouteManager> - )} - </WorkpadLoaderComponent> - )} - /> -); +export const WorkpadRoute = () => { + return ( + <Route + path={['/workpad/:id/page/:pageNumber', '/workpad/:id']} + exact={false} + children={(route: WorkpadRouteProps) => { + return <WorkpadRouteComponent route={route} />; + }} + /> + ); +}; + +const WorkpadRouteComponent: FC<{ route: WorkpadRouteProps }> = ({ route }) => { + const getRedirectPath = useCallback( + (workpadId: string) => + `/workpad/${workpadId}${ + route.match.params.pageNumber ? `/page/${route.match.params.pageNumber}` : '' + }`, + [route.match.params.pageNumber] + ); + + return ( + <WorkpadLoaderComponent + params={route.match.params} + key="workpad-loader" + getRedirectPath={getRedirectPath} + > + {(workpad: CanvasWorkpad) => ( + <Switch> + <Route + path="/workpad/:id/page/:pageNumber" + children={(pageRoute) => ( + <WorkpadHistoryManager> + <WorkpadRoutingContextComponent> + <WorkpadPresentationHelper> + <WorkpadApp /> + </WorkpadPresentationHelper> + </WorkpadRoutingContextComponent> + </WorkpadHistoryManager> + )} + /> + <Route path="/workpad/:id" strict={false} exact={true}> + <Redirect to={`/workpad/${route.match.params.id}/page/${workpad.page + 1}`} /> + </Route> + </Switch> + )} + </WorkpadLoaderComponent> + ); +}; + +export const ExportWorkpadRoute = () => { + return ( + <Route + path={'/export/workpad/pdf/:id/page/:pageNumber'} + children={(route: WorkpadRouteProps) => { + return <ExportWorkpadRouteComponent route={route} />; + }} + /> + ); +}; + +const ExportWorkpadRouteComponent: FC<{ route: WorkpadRouteProps }> = ({ route: { match } }) => { + const getRedirectPath = useCallback( + (workpadId: string) => `/export/workpad/pdf/${workpadId}/page/${match.params.pageNumber}`, + [match.params.pageNumber] + ); + + return ( + <WorkpadLoaderComponent + loadPages={false} + params={match.params} + getRedirectPath={getRedirectPath} + > + {() => ( + <ExportRouteManager> + <ExportApp /> + </ExportRouteManager> + )} + </WorkpadLoaderComponent> + ); +}; export const ExportRouteManager: FC = ({ children }) => { const params = useParams<WorkpadPageRouteParams>(); @@ -97,9 +134,10 @@ export const WorkpadHistoryManager: FC = ({ children }) => { const WorkpadLoaderComponent: FC<{ params: WorkpadRouteProps['match']['params']; loadPages?: boolean; + getRedirectPath: (workpadId: string) => string; children: (workpad: CanvasWorkpad) => JSX.Element; -}> = ({ params, children, loadPages }) => { - const [workpad, error] = useWorkpad(params.id, loadPages); +}> = ({ params, children, loadPages, getRedirectPath }) => { + const [workpad, error] = useWorkpad(params.id, loadPages, getRedirectPath); const notifyService = useNotifyService(); useEffect(() => { diff --git a/x-pack/plugins/canvas/public/services/kibana/platform.ts b/x-pack/plugins/canvas/public/services/kibana/platform.ts index dc524aab6f444..aea8c6e7f7a95 100644 --- a/x-pack/plugins/canvas/public/services/kibana/platform.ts +++ b/x-pack/plugins/canvas/public/services/kibana/platform.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { KibanaPluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; import { CanvasStartDeps } from '../../plugin'; @@ -15,7 +14,11 @@ export type CanvaPlatformServiceFactory = KibanaPluginServiceFactory< CanvasStartDeps >; -export const platformServiceFactory: CanvaPlatformServiceFactory = ({ coreStart, initContext }) => { +export const platformServiceFactory: CanvaPlatformServiceFactory = ({ + coreStart, + initContext, + startPlugins, +}) => { if (!initContext) { throw new Error('Canvas platform service requires init context'); } @@ -34,6 +37,8 @@ export const platformServiceFactory: CanvaPlatformServiceFactory = ({ coreStart, setBreadcrumbs: coreStart.chrome.setBreadcrumbs, setRecentlyAccessed: coreStart.chrome.recentlyAccessed.add, setFullscreen: coreStart.chrome.setIsVisible, + redirectLegacyUrl: startPlugins.spaces?.ui.redirectLegacyUrl, + getLegacyUrlConflict: startPlugins.spaces?.ui.components.getLegacyUrlConflict, // TODO: these should go away. We want thin accessors, not entire objects. // Entire objects are hard to mock, and hide our dependency on the external service. diff --git a/x-pack/plugins/canvas/public/services/kibana/workpad.ts b/x-pack/plugins/canvas/public/services/kibana/workpad.ts index 8609d5055cb83..35b82735845d0 100644 --- a/x-pack/plugins/canvas/public/services/kibana/workpad.ts +++ b/x-pack/plugins/canvas/public/services/kibana/workpad.ts @@ -8,7 +8,7 @@ import { KibanaPluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; import { CanvasStartDeps } from '../../plugin'; -import { CanvasWorkpadService } from '../workpad'; +import { CanvasWorkpadService, ResolveWorkpadResponse } from '../workpad'; import { API_ROUTE_WORKPAD, @@ -67,6 +67,23 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ coreStart, return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; }, + resolve: async (id: string) => { + const { workpad, outcome, aliasId } = await coreStart.http.get<ResolveWorkpadResponse>( + `${getApiPath()}/resolve/${id}` + ); + + return { + outcome, + aliasId, + workpad: { + // @ts-ignore: Shimming legacy workpads that might not have CSS + css: DEFAULT_WORKPAD_CSS, + // @ts-ignore: Shimming legacy workpads that might not have variables + variables: [], + ...workpad, + }, + }; + }, create: (workpad: CanvasWorkpad) => { return coreStart.http.post(getApiPath(), { body: JSON.stringify({ diff --git a/x-pack/plugins/canvas/public/services/platform.ts b/x-pack/plugins/canvas/public/services/platform.ts index 9bff61a0c668a..c476fac3b8789 100644 --- a/x-pack/plugins/canvas/public/services/platform.ts +++ b/x-pack/plugins/canvas/public/services/platform.ts @@ -15,6 +15,8 @@ import { ChromeStart, } from '../../../../../src/core/public'; +import { SpacesPluginStart } from '../../../spaces/public'; + export interface CanvasPlatformService { getBasePath: () => string; getBasePathInterface: () => IBasePath; @@ -27,6 +29,8 @@ export interface CanvasPlatformService { setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; setRecentlyAccessed: (link: string, label: string, id: string) => void; setFullscreen: ChromeStart['setIsVisible']; + redirectLegacyUrl?: SpacesPluginStart['ui']['redirectLegacyUrl']; + getLegacyUrlConflict?: SpacesPluginStart['ui']['components']['getLegacyUrlConflict']; // TODO: these should go away. We want thin accessors, not entire objects. // Entire objects are hard to mock, and hide our dependency on the external service. diff --git a/x-pack/plugins/canvas/public/services/storybook/workpad.ts b/x-pack/plugins/canvas/public/services/storybook/workpad.ts index e4d176f490071..6c77bdb1adeac 100644 --- a/x-pack/plugins/canvas/public/services/storybook/workpad.ts +++ b/x-pack/plugins/canvas/public/services/storybook/workpad.ts @@ -77,6 +77,10 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ action('workpadService.get')(id); return Promise.resolve({ ...getDefaultWorkpad(), id }); }, + resolve: (id: string) => { + action('workpadService.resolve')(id); + return Promise.resolve({ outcome: 'exactMatch', workpad: { ...getDefaultWorkpad(), id } }); + }, findTemplates: () => { action('workpadService.findTemplates')(); return (hasTemplates ? findSomeTemplates() : findNoTemplates())(); diff --git a/x-pack/plugins/canvas/public/services/stubs/platform.ts b/x-pack/plugins/canvas/public/services/stubs/platform.ts index 3942fcf145cee..71a252028a242 100644 --- a/x-pack/plugins/canvas/public/services/stubs/platform.ts +++ b/x-pack/plugins/canvas/public/services/stubs/platform.ts @@ -34,4 +34,6 @@ export const platformServiceFactory: CanvasPlatformServiceFactory = () => ({ getSavedObjectsClient: noop, getUISettings: noop, setFullscreen: noop, + redirectLegacyUrl: noop, + getLegacyUrlConflict: undefined, }); diff --git a/x-pack/plugins/canvas/public/services/stubs/workpad.ts b/x-pack/plugins/canvas/public/services/stubs/workpad.ts index 19979e51b5e46..c10244038750d 100644 --- a/x-pack/plugins/canvas/public/services/stubs/workpad.ts +++ b/x-pack/plugins/canvas/public/services/stubs/workpad.ts @@ -99,6 +99,8 @@ export const getSomeTemplates = () => ({ templates }); export const workpadServiceFactory: CanvasWorkpadServiceFactory = () => ({ get: (id: string) => Promise.resolve({ ...getDefaultWorkpad(), id }), + resolve: (id: string) => + Promise.resolve({ outcome: 'exactMatch', workpad: { ...getDefaultWorkpad(), id } }), findTemplates: findNoTemplates(), create: (workpad) => Promise.resolve(workpad), createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()), diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index c0e948669647c..8e77ab3f321ef 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { SavedObjectsResolveResponse } from 'src/core/public'; import { CanvasWorkpad, CanvasTemplate } from '../../types'; import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; @@ -18,8 +19,15 @@ export interface WorkpadFindResponse { export interface TemplateFindResponse { templates: CanvasTemplate[]; } + +export interface ResolveWorkpadResponse { + workpad: CanvasWorkpad; + outcome: SavedObjectsResolveResponse['outcome']; + aliasId?: SavedObjectsResolveResponse['alias_target_id']; +} export interface CanvasWorkpadService { get: (id: string) => Promise<CanvasWorkpad>; + resolve: (id: string) => Promise<ResolveWorkpadResponse>; create: (workpad: CanvasWorkpad) => Promise<CanvasWorkpad>; createFromTemplate: (templateId: string) => Promise<CanvasWorkpad>; find: (term: string) => Promise<WorkpadFindResponse>; diff --git a/x-pack/plugins/canvas/public/style/main.scss b/x-pack/plugins/canvas/public/style/main.scss index 4f293e9072e4f..b53181bef85b9 100644 --- a/x-pack/plugins/canvas/public/style/main.scss +++ b/x-pack/plugins/canvas/public/style/main.scss @@ -11,6 +11,7 @@ $canvasElementCardWidth: 210px; .canvas.canvasContainer { display: flex; flex-grow: 1; + flex-direction: column; background-color: $euiPageBackgroundColor; } diff --git a/x-pack/plugins/canvas/server/mocks/workpad_route_context.ts b/x-pack/plugins/canvas/server/mocks/workpad_route_context.ts index abba97639a4c9..216cdc0970dc4 100644 --- a/x-pack/plugins/canvas/server/mocks/workpad_route_context.ts +++ b/x-pack/plugins/canvas/server/mocks/workpad_route_context.ts @@ -13,6 +13,7 @@ export interface MockWorkpadRouteContext extends CanvasRouteHandlerContext { create: jest.Mock; get: jest.Mock; update: jest.Mock; + resolve: jest.Mock; }; }; } @@ -23,6 +24,7 @@ export const workpadRouteContextMock = { create: jest.fn(), get: jest.fn(), update: jest.fn(), + resolve: jest.fn(), }, }), }; diff --git a/x-pack/plugins/canvas/server/routes/workpad/get.ts b/x-pack/plugins/canvas/server/routes/workpad/get.ts index ff3ed4bad55b9..11e39adc4ca32 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/get.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/get.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { RouteInitializerDeps } from '../'; import { API_ROUTE_WORKPAD } from '../../../common/lib/constants'; import { catchErrorHandler } from '../catch_error_handler'; +import { shimWorkpad } from './shim_workpad'; export function initializeGetWorkpadRoute(deps: RouteInitializerDeps) { const { router } = deps; @@ -24,24 +25,7 @@ export function initializeGetWorkpadRoute(deps: RouteInitializerDeps) { catchErrorHandler(async (context, request, response) => { const workpad = await context.canvas.workpad.get(request.params.id); - if ( - // not sure if we need to be this defensive - workpad.type === 'canvas-workpad' && - workpad.attributes && - workpad.attributes.pages && - workpad.attributes.pages.length - ) { - workpad.attributes.pages.forEach((page) => { - const elements = (page.elements || []).filter( - ({ id: pageId }) => !pageId.startsWith('group') - ); - const groups = (page.groups || []).concat( - (page.elements || []).filter(({ id: pageId }) => pageId.startsWith('group')) - ); - page.elements = elements; - page.groups = groups; - }); - } + shimWorkpad(workpad); return response.ok({ body: { diff --git a/x-pack/plugins/canvas/server/routes/workpad/index.ts b/x-pack/plugins/canvas/server/routes/workpad/index.ts index 6a7a080047b91..8483642e59c5a 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/index.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/index.ts @@ -11,9 +11,11 @@ import { initializeGetWorkpadRoute } from './get'; import { initializeCreateWorkpadRoute } from './create'; import { initializeUpdateWorkpadRoute, initializeUpdateWorkpadAssetsRoute } from './update'; import { initializeDeleteWorkpadRoute } from './delete'; +import { initializeResolveWorkpadRoute } from './resolve'; export function initWorkpadRoutes(deps: RouteInitializerDeps) { initializeFindWorkpadsRoute(deps); + initializeResolveWorkpadRoute(deps); initializeGetWorkpadRoute(deps); initializeCreateWorkpadRoute(deps); initializeUpdateWorkpadRoute(deps); diff --git a/x-pack/plugins/canvas/server/routes/workpad/resolve.test.ts b/x-pack/plugins/canvas/server/routes/workpad/resolve.test.ts new file mode 100644 index 0000000000000..bc93e115e137f --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/workpad/resolve.test.ts @@ -0,0 +1,135 @@ +/* + * 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 { CANVAS_TYPE } from '../../../common/lib/constants'; +import { initializeResolveWorkpadRoute } from './resolve'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; +import { workpadWithGroupAsElement } from '../../../__fixtures__/workpads'; +import { CanvasWorkpad } from '../../../types'; +import { getMockedRouterDeps } from '../test_helpers'; +import { workpadRouteContextMock, MockWorkpadRouteContext } from '../../mocks'; + +const mockRouteContext = { + canvas: workpadRouteContextMock.create(), +} as unknown as MockWorkpadRouteContext; + +describe('RESOLVE workpad', () => { + let routeHandler: RequestHandler<any, any, any>; + + beforeEach(() => { + const routerDeps = getMockedRouterDeps(); + initializeResolveWorkpadRoute(routerDeps); + + routeHandler = routerDeps.router.get.mock.calls[0][1]; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it(`returns 200 when the workpad is found`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path: 'api/canvas/workpad/resolve/123', + params: { + id: '123', + }, + }); + + const outcome = 'aliasMatch'; + const aliasId = 'alias-id'; + + mockRouteContext.canvas.workpad.resolve.mockResolvedValue({ + saved_object: { + id: '123', + type: CANVAS_TYPE, + attributes: { foo: true }, + references: [], + }, + outcome, + alias_target_id: aliasId, + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot(` + Object { + "aliasId": "alias-id", + "outcome": "aliasMatch", + "workpad": Object { + "foo": true, + "id": "123", + }, + } + `); + + expect(mockRouteContext.canvas.workpad.resolve.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "123", + ], + ] + `); + }); + + it('corrects elements that should be groups', async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path: 'api/canvas/workpad/resolve/123', + params: { + id: '123', + }, + }); + + mockRouteContext.canvas.workpad.resolve.mockResolvedValue({ + saved_object: { + id: '123', + type: CANVAS_TYPE, + attributes: workpadWithGroupAsElement as any, + references: [], + }, + outcome: 'exactMatch', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + const workpad = response.payload.workpad as CanvasWorkpad; + + expect(response.status).toBe(200); + expect(workpad).not.toBeUndefined(); + + expect(workpad.pages[0].elements.length).toBe(1); + expect(workpad.pages[0].groups.length).toBe(1); + }); + + it('returns 404 if the workpad is not found', async () => { + const id = '123'; + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path: 'api/canvas/workpad/resolve/123', + params: { + id, + }, + }); + + const savedObjectsClient = savedObjectsClientMock.create(); + mockRouteContext.canvas.workpad.resolve.mockImplementation(() => { + throw savedObjectsClient.errors.createGenericNotFoundError(CANVAS_TYPE, id); + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.payload).toMatchInlineSnapshot(` + Object { + "error": "Not Found", + "message": "Saved object [canvas-workpad/123] not found", + "statusCode": 404, + } + `); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/workpad/resolve.ts b/x-pack/plugins/canvas/server/routes/workpad/resolve.ts new file mode 100644 index 0000000000000..7c21ecf9ed055 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/workpad/resolve.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteInitializerDeps } from '../'; +import { API_ROUTE_WORKPAD } from '../../../common/lib/constants'; +import { catchErrorHandler } from '../catch_error_handler'; +import { shimWorkpad } from './shim_workpad'; + +export function initializeResolveWorkpadRoute(deps: RouteInitializerDeps) { + const { router } = deps; + router.get( + { + path: `${API_ROUTE_WORKPAD}/resolve/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + catchErrorHandler(async (context, request, response) => { + const resolved = await context.canvas.workpad.resolve(request.params.id); + const { saved_object: workpad } = resolved; + + shimWorkpad(workpad); + + return response.ok({ + body: { + workpad: { + id: workpad.id, + ...workpad.attributes, + }, + outcome: resolved.outcome, + aliasId: resolved.alias_target_id, + }, + }); + }) + ); +} diff --git a/x-pack/plugins/canvas/server/routes/workpad/shim_workpad.ts b/x-pack/plugins/canvas/server/routes/workpad/shim_workpad.ts new file mode 100644 index 0000000000000..63fa5bb252985 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/workpad/shim_workpad.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObject } from 'kibana/server'; +import { WorkpadAttributes } from './workpad_attributes'; + +export function shimWorkpad(workpad: SavedObject<WorkpadAttributes>) { + if ( + // not sure if we need to be this defensive + workpad.type === 'canvas-workpad' && + workpad.attributes && + workpad.attributes.pages && + workpad.attributes.pages.length + ) { + workpad.attributes.pages.forEach((page) => { + const elements = (page.elements || []).filter( + ({ id: pageId }) => !pageId.startsWith('group') + ); + const groups = (page.groups || []).concat( + (page.elements || []).filter(({ id: pageId }) => pageId.startsWith('group')) + ); + page.elements = elements; + page.groups = groups; + }); + } +} diff --git a/x-pack/plugins/canvas/server/workpad_route_context.test.ts b/x-pack/plugins/canvas/server/workpad_route_context.test.ts index d13b8aa9eb634..6496edf1a0e12 100644 --- a/x-pack/plugins/canvas/server/workpad_route_context.test.ts +++ b/x-pack/plugins/canvas/server/workpad_route_context.test.ts @@ -141,6 +141,31 @@ describe('workpad route context', () => { }); }); + describe('RESOLVE', () => { + it('injects references to the saved object', async () => { + const id = 'so-id'; + const canvasContext = await workpadRouteContext( + mockContext, + undefined as any, + undefined as any + ); + + (mockContext.core.savedObjects.client.resolve as jest.Mock).mockResolvedValue({ + saved_object: { attributes: extractedWorkpad, references }, + outcome: 'exactMatch', + }); + + mockedExpressionService.inject.mockReturnValue(fromExpression(injectedExpression)); + + const result = await canvasContext.workpad.resolve(id); + const { id: ingnoredId, ...expectedAttributes } = injectedWorkpad; + + expect(mockContext.core.savedObjects.client.resolve).toBeCalledWith(CANVAS_TYPE, id); + + expect(result.saved_object.attributes).toEqual(expectedAttributes); + }); + }); + describe('UPDATE', () => { it('extracts from the given attributes', async () => { const id = 'workpad-id'; diff --git a/x-pack/plugins/canvas/server/workpad_route_context.ts b/x-pack/plugins/canvas/server/workpad_route_context.ts index 5689bf9961f76..9727327fcbd79 100644 --- a/x-pack/plugins/canvas/server/workpad_route_context.ts +++ b/x-pack/plugins/canvas/server/workpad_route_context.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { RequestHandlerContext, RequestHandlerContextProvider, SavedObject } from 'kibana/server'; +import { + RequestHandlerContext, + RequestHandlerContextProvider, + SavedObject, + SavedObjectsResolveResponse, +} from 'kibana/server'; import { ExpressionsService } from 'src/plugins/expressions'; import { WorkpadAttributes } from './routes/workpad/workpad_attributes'; import { CANVAS_TYPE } from '../common/lib/constants'; @@ -18,6 +23,7 @@ export interface CanvasRouteHandlerContext extends RequestHandlerContext { workpad: { create: (attributes: CanvasWorkpad) => Promise<SavedObject<WorkpadAttributes>>; get: (id: string) => Promise<SavedObject<WorkpadAttributes>>; + resolve: (id: string) => Promise<SavedObjectsResolveResponse<WorkpadAttributes>>; update: ( id: string, attributes: Partial<CanvasWorkpad> @@ -66,6 +72,20 @@ export const createWorkpadRouteContext: ( return workpad; }, + resolve: async (id: string) => { + const resolved = await context.core.savedObjects.client.resolve<WorkpadAttributes>( + CANVAS_TYPE, + id + ); + + resolved.saved_object.attributes = injectReferences( + resolved.saved_object.attributes, + resolved.saved_object.references, + expressions + ); + + return resolved; + }, update: async (id: string, { id: omittedId, ...workpad }: Partial<CanvasWorkpad>) => { const now = new Date().toISOString(); diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json index 5a5a1883240b7..5e3c7a7299adb 100644 --- a/x-pack/plugins/canvas/tsconfig.json +++ b/x-pack/plugins/canvas/tsconfig.json @@ -49,6 +49,7 @@ { "path": "../features/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, - { "path": "../reporting/tsconfig.json" } + { "path": "../reporting/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, ] } diff --git a/x-pack/plugins/canvas/types/canvas.ts b/x-pack/plugins/canvas/types/canvas.ts index d3799cf59745a..0868054d0a489 100644 --- a/x-pack/plugins/canvas/types/canvas.ts +++ b/x-pack/plugins/canvas/types/canvas.ts @@ -53,6 +53,7 @@ export interface CanvasWorkpad { variables: CanvasVariable[]; height: number; id: string; + aliasId?: string; isWriteable: boolean; name: string; page: number; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx index 8496be6223764..ea01ad974b9a1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { SuggestedCurationsCallout } from '../../engine_overview/components/suggested_curations_callout'; import { AnalyticsCards, AnalyticsChart, @@ -40,6 +41,7 @@ describe('Analytics overview', () => { }); const wrapper = shallow(<Analytics />); + expect(wrapper.find(SuggestedCurationsCallout)).toHaveLength(1); expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); expect(wrapper.find(AnalyticsSection)).toHaveLength(2); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx index 0eef9b0c688c0..aa949a01f7d79 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx @@ -25,6 +25,7 @@ import { import { DataPanel } from '../../data_panel'; import { generateEnginePath } from '../../engine'; +import { SuggestedCurationsCallout } from '../../engine_overview/components/suggested_curations_callout'; import { AnalyticsLayout } from '../analytics_layout'; import { AnalyticsSection, AnalyticsTable, RecentQueriesTable } from '../components'; import { @@ -60,6 +61,7 @@ export const Analytics: React.FC = () => { return ( <AnalyticsLayout isAnalyticsView title={ANALYTICS_TITLE}> + <SuggestedCurationsCallout /> <EuiFlexGroup alignItems="center"> <EuiFlexItem grow={1}> <AnalyticsCards diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.scss new file mode 100644 index 0000000000000..cdff5042f89b7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.scss @@ -0,0 +1,5 @@ +.curationsTable { + .curationsTableBadge { + margin-left: $euiSizeS; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.test.tsx index 0b647bffb3e26..fb5db2f293464 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.test.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { shallow, ReactWrapper } from 'enzyme'; -import { EuiBasicTable } from '@elastic/eui'; +import { EuiBadge, EuiBasicTable } from '@elastic/eui'; import { mountWithIntl } from '../../../../test_helpers'; @@ -29,11 +29,17 @@ describe('CurationsTable', () => { id: 'cur-id-1', last_updated: 'January 1, 1970 at 12:00PM', queries: ['hiking'], + suggestion: { + status: 'automated', + }, }, { id: 'cur-id-2', last_updated: 'January 2, 1970 at 12:00PM', queries: ['mountains', 'valleys'], + suggestion: { + status: 'pending', + }, }, ], meta: { @@ -82,14 +88,18 @@ describe('CurationsTable', () => { expect(tableContent).toContain('Jan 2, 1970 12:00 PM'); }); - it('renders queries with curation links', () => { - expect( - wrapper.find('EuiLinkTo[data-test-subj="CurationsTableQueriesLink"]').first().prop('to') - ).toEqual('/engines/some-engine/curations/cur-id-1'); - - expect( - wrapper.find('EuiLinkTo[data-test-subj="CurationsTableQueriesLink"]').last().prop('to') - ).toEqual('/engines/some-engine/curations/cur-id-2'); + it('renders queries with curation links and curation suggestion badges', () => { + const firstQueryLink = wrapper + .find('EuiLinkTo[data-test-subj="CurationsTableQueriesLink"]') + .first(); + const secondQueryLink = wrapper + .find('EuiLinkTo[data-test-subj="CurationsTableQueriesLink"]') + .last(); + + expect(firstQueryLink.prop('to')).toEqual('/engines/some-engine/curations/cur-id-1'); + expect(firstQueryLink.find(EuiBadge).prop('children')).toEqual('Automated'); + expect(secondQueryLink.prop('to')).toEqual('/engines/some-engine/curations/cur-id-2'); + expect(secondQueryLink.find(EuiBadge).prop('children')).toEqual('New suggestion'); }); describe('action column', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.tsx index 19837a7f4d3a2..183d2b9cb7107 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curations_table.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues, useActions } from 'kea'; -import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiBadge, EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EDIT_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../../../../shared/constants'; @@ -26,6 +26,10 @@ import { CurationsLogic } from '../curations_logic'; import { Curation } from '../types'; import { convertToDate } from '../utils'; +import { AutomatedIcon } from './automated_icon'; + +import './curations_table.scss'; + export const CurationsTable: React.FC = () => { const { dataLoading, curations, meta } = useValues(CurationsLogic); const { onPaginate, deleteCuration } = useActions(CurationsLogic); @@ -43,6 +47,26 @@ export const CurationsTable: React.FC = () => { to={generateEnginePath(ENGINE_CURATION_PATH, { curationId: curation.id })} > {queries.join(', ')} + {curation.suggestion?.status === 'automated' && ( + <> + <EuiBadge color="accent" iconType={AutomatedIcon} className="curationsTableBadge"> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.table.automatedLabel', + { defaultMessage: 'Automated' } + )} + </EuiBadge> + </> + )} + {curation.suggestion?.status === 'pending' && ( + <> + <EuiBadge color="default" className="curationsTableBadge"> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.table.newSuggestionLabel', + { defaultMessage: 'New suggestion' } + )} + </EuiBadge> + </> + )} </EuiLinkTo> ), width: '40%', @@ -103,6 +127,7 @@ export const CurationsTable: React.FC = () => { return ( <DataPanel + className="curationsTable" hasBorder iconType="package" title={ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.test.tsx new file mode 100644 index 0000000000000..1732b6f746691 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.test.tsx @@ -0,0 +1,77 @@ +/* + * 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__/react_router'; + +jest.mock('../../../../shared/use_local_storage', () => ({ + useLocalStorage: jest.fn(), +})); + +import React from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { shallow } from 'enzyme'; + +import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui'; + +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { useLocalStorage } from '../../../../shared/use_local_storage'; + +import { SuggestionsCallout } from './suggestions_callout'; + +const props = { + title: 'Title', + description: 'A description.', + buttonTo: '/suggestions', +}; + +const now = '2021-01-01T00:30:00Z'; +const tenMinutesAgo = '2021-01-01T00:20:00Z'; +const twentyMinutesAgo = '2021-01-01T00:10:00Z'; + +describe('SuggestionsCallout', () => { + const mockSetLastDismissedTimestamp = jest.fn(); + const setMockLastDismissedTimestamp = (lastDismissedTimestamp: string) => { + (useLocalStorage as jest.Mock).mockImplementation(() => [ + lastDismissedTimestamp, + mockSetLastDismissedTimestamp, + ]); + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockLastDismissedTimestamp(tenMinutesAgo); + (useLocation as jest.Mock).mockImplementationOnce(() => ({ + pathname: '/engines/some-engine', + })); + }); + + it('renders a callout with a link to the suggestions', () => { + const wrapper = shallow(<SuggestionsCallout {...props} lastUpdatedTimestamp={now} />); + + expect(wrapper.find(EuiCallOut)); + expect(wrapper.find(EuiButtonTo).prop('to')).toEqual('/suggestions'); + }); + + it('is empty is it was updated before it was last dismissed', () => { + const wrapper = shallow( + <SuggestionsCallout {...props} lastUpdatedTimestamp={twentyMinutesAgo} /> + ); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('clicking the dismiss button updates the timestamp in local storage', () => { + jest.spyOn(global.Date.prototype, 'toISOString').mockImplementation(() => now); + + const wrapper = shallow(<SuggestionsCallout {...props} lastUpdatedTimestamp={now} />); + wrapper.find(EuiButtonEmpty).simulate('click'); + + expect(mockSetLastDismissedTimestamp).toHaveBeenCalledWith(now); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.tsx new file mode 100644 index 0000000000000..490e6323290f0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_callout.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { LightbulbIcon } from '../../../../shared/icons'; +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { useLocalStorage } from '../../../../shared/use_local_storage'; + +interface SuggestionsCalloutProps { + title: string; + description: string; + buttonTo: string; + lastUpdatedTimestamp: string; // ISO string like '2021-10-04T18:53:02.784Z' +} + +export const SuggestionsCallout: React.FC<SuggestionsCalloutProps> = ({ + title, + description, + buttonTo, + lastUpdatedTimestamp, +}) => { + const { pathname } = useLocation(); + + const [lastDismissedTimestamp, setLastDismissedTimestamp] = useLocalStorage<string>( + `suggestions-callout--${pathname}`, + new Date(0).toISOString() + ); + + if (new Date(lastDismissedTimestamp) >= new Date(lastUpdatedTimestamp)) { + return null; + } + + return ( + <> + <EuiCallOut color="success" iconType={LightbulbIcon} title={title}> + <EuiText size="s"> + <p>{description}</p> + </EuiText> + <EuiSpacer size="m" /> + <EuiFlexGroup gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiButtonTo to={buttonTo} color="success" fill size="s"> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.suggestionsCallout.reviewSuggestionsButtonLabel', + { defaultMessage: 'Review suggestions' } + )} + </EuiButtonTo> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + color="success" + iconType="eyeClosed" + size="s" + onClick={() => { + setLastDismissedTimestamp(new Date().toISOString()); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.suggestionsCallout.hideForNowLabel', + { defaultMessage: 'Hide this for now' } + )} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + </EuiCallOut> + <EuiSpacer /> + </> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx index 5afbce3661da3..bf64101527fd2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx @@ -16,7 +16,7 @@ import { nextTick } from '@kbn/test/jest'; import { DEFAULT_META } from '../../../../shared/constants'; -import { SuggestionsLogic } from './suggestions_logic'; +import { SuggestionsAPIResponse, SuggestionsLogic } from './suggestions_logic'; const DEFAULT_VALUES = { dataLoading: true, @@ -30,7 +30,7 @@ const DEFAULT_VALUES = { }, }; -const MOCK_RESPONSE = { +const MOCK_RESPONSE: SuggestionsAPIResponse = { meta: { page: { current: 1, @@ -44,6 +44,7 @@ const MOCK_RESPONSE = { query: 'foo', updated_at: '2021-07-08T14:35:50Z', promoted: ['1', '2'], + status: 'applied', }, ], }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx index 9352bdab51edd..074d2114ee8cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx @@ -15,7 +15,7 @@ import { updateMetaPageIndex } from '../../../../shared/table_pagination'; import { EngineLogic } from '../../engine'; import { CurationSuggestion } from '../types'; -interface SuggestionsAPIResponse { +export interface SuggestionsAPIResponse { results: CurationSuggestion[]; meta: Meta; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.scss new file mode 100644 index 0000000000000..9c88bc1e3dc03 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.scss @@ -0,0 +1,5 @@ +.suggestionsTable { + .suggestionsTableBadge { + margin-left: $euiSizeS; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx index b49cea2519eda..28c368d942c1f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx @@ -11,9 +11,9 @@ import '../../../__mocks__/engine_logic.mock'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiBasicTable } from '@elastic/eui'; +import { EuiBadge, EuiBasicTable } from '@elastic/eui'; import { SuggestionsTable } from './suggestions_table'; @@ -75,13 +75,23 @@ describe('SuggestionsTable', () => { }); it('show a suggestions query with a link', () => { - const wrapper = renderColumn(0)('test'); + const wrapper = renderColumn(0)('test', {}); expect(wrapper.prop('href')).toBe( '/app/enterprise_search/engines/some-engine/curations/suggestions/test' ); expect(wrapper.text()).toEqual('test'); }); + it('show a badge when there are overrides', () => { + let wrapper: ShallowWrapper; + + wrapper = renderColumn(0)('test', {}); + expect(wrapper.find(EuiBadge)).toHaveLength(0); + + wrapper = renderColumn(0)('test', { override_curation_id: '1-2-3' }); + expect(wrapper.find(EuiBadge).prop('children')).toEqual('Overrides'); + }); + it('contains an updated at timestamp', () => { const wrapper = renderColumn(1)('2021-07-08T14:35:50Z'); expect(wrapper.find('FormattedDate').exists()).toBe(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx index 779b86ce5156e..2a252a9666ac1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiBadge, EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { VIEW_BUTTON_LABEL } from '../../../../shared/constants'; @@ -26,6 +26,8 @@ import { convertToDate } from '../utils'; import { SuggestionsLogic } from './suggestions_logic'; +import './suggestions_table.scss'; + const getSuggestionRoute = (query: string) => { return generateEnginePath(ENGINE_CURATION_SUGGESTION_PATH, { query }); }; @@ -37,7 +39,21 @@ const columns: Array<EuiBasicTableColumn<CurationSuggestion>> = [ 'xpack.enterpriseSearch.appSearch.engine.curations.suggestionsTable.column.queryTableHeader', { defaultMessage: 'Query' } ), - render: (query: string) => <EuiLinkTo to={getSuggestionRoute(query)}>{query}</EuiLinkTo>, + render: (query: string, curation: CurationSuggestion) => ( + <EuiLinkTo to={getSuggestionRoute(query)}> + {query} + {curation.override_curation_id && ( + <> + <EuiBadge iconType="alert" color="warning" className="suggestionsTableBadge"> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.suggestionsTable.overridesLabel', + { defaultMessage: 'Overrides' } + )} + </EuiBadge> + </> + )} + </EuiLinkTo> + ), }, { field: 'updated_at', @@ -89,6 +105,7 @@ export const SuggestionsTable: React.FC = () => { return ( <DataPanel + className="suggestionsTable" iconType={LightbulbIcon} title={ <h2> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts index f8c3e3efdbc1d..01ca80776ae85 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts @@ -50,6 +50,13 @@ export const RESTORE_CONFIRMATION = i18n.translate( } ); +export const CONVERT_TO_MANUAL_CONFIRMATION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.convertToManualCurationConfirmation', + { + defaultMessage: 'Are you sure you want to convert this to a manual curation?', + } +); + export const RESULT_ACTIONS_DIRECTIONS = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.resultActionsDescription', { defaultMessage: 'Promote results by clicking the star, hide them by clicking the eye.' } @@ -82,3 +89,13 @@ export const SHOW_DOCUMENT_ACTION = { iconType: 'eye', iconColor: 'primary' as EuiButtonIconColor, }; + +export const AUTOMATED_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curation.automatedLabel', + { defaultMessage: 'Automated' } +); + +export const COVERT_TO_MANUAL_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curation.convertToManualCurationButtonLabel', + { defaultMessage: 'Convert to manual curation' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.test.tsx new file mode 100644 index 0000000000000..3139d62863729 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../__mocks__/react_router'; +import '../../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiBadge } from '@elastic/eui'; + +import { getPageHeaderActions, getPageTitle } from '../../../../test_helpers'; + +jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); + +import { AppSearchPageTemplate } from '../../layout'; + +import { AutomatedCuration } from './automated_curation'; +import { CurationLogic } from './curation_logic'; + +import { PromotedDocuments, OrganicDocuments } from './documents'; + +describe('AutomatedCuration', () => { + const values = { + dataLoading: false, + queries: ['query A', 'query B'], + isFlyoutOpen: false, + curation: { + suggestion: { + status: 'applied', + }, + }, + activeQuery: 'query A', + isAutomated: true, + }; + + const actions = { + convertToManual: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + mockUseParams.mockReturnValue({ curationId: 'test' }); + }); + + it('renders', () => { + const wrapper = shallow(<AutomatedCuration />); + + expect(wrapper.is(AppSearchPageTemplate)); + expect(wrapper.find(PromotedDocuments)).toHaveLength(1); + expect(wrapper.find(OrganicDocuments)).toHaveLength(1); + }); + + it('initializes CurationLogic with a curationId prop from URL param', () => { + mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' }); + shallow(<AutomatedCuration />); + + expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' }); + }); + + it('displays the query in the title with a badge', () => { + const wrapper = shallow(<AutomatedCuration />); + const pageTitle = shallow(<div>{getPageTitle(wrapper)}</div>); + + expect(pageTitle.text()).toContain('query A'); + expect(pageTitle.find(EuiBadge)).toHaveLength(1); + }); + + describe('convert to manual button', () => { + let convertToManualButton: ShallowWrapper; + let confirmSpy: jest.SpyInstance; + + beforeAll(() => { + const wrapper = shallow(<AutomatedCuration />); + convertToManualButton = getPageHeaderActions(wrapper).childAt(0); + + confirmSpy = jest.spyOn(window, 'confirm'); + }); + + afterAll(() => { + confirmSpy.mockRestore(); + }); + + it('converts the curation upon user confirmation', () => { + confirmSpy.mockReturnValueOnce(true); + convertToManualButton.simulate('click'); + expect(actions.convertToManual).toHaveBeenCalled(); + }); + + it('does not convert the curation if the user cancels', () => { + confirmSpy.mockReturnValueOnce(false); + convertToManualButton.simulate('click'); + expect(actions.convertToManual).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.tsx new file mode 100644 index 0000000000000..1415537e42d6e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/automated_curation.tsx @@ -0,0 +1,65 @@ +/* + * 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 { useParams } from 'react-router-dom'; + +import { useValues, useActions } from 'kea'; + +import { EuiSpacer, EuiButton, EuiBadge } from '@elastic/eui'; + +import { AppSearchPageTemplate } from '../../layout'; +import { AutomatedIcon } from '../components/automated_icon'; +import { + AUTOMATED_LABEL, + COVERT_TO_MANUAL_BUTTON_LABEL, + CONVERT_TO_MANUAL_CONFIRMATION, +} from '../constants'; +import { getCurationsBreadcrumbs } from '../utils'; + +import { CurationLogic } from './curation_logic'; +import { PromotedDocuments, OrganicDocuments } from './documents'; + +export const AutomatedCuration: React.FC = () => { + const { curationId } = useParams<{ curationId: string }>(); + const logic = CurationLogic({ curationId }); + const { convertToManual } = useActions(logic); + const { activeQuery, dataLoading, queries } = useValues(logic); + + return ( + <AppSearchPageTemplate + pageChrome={getCurationsBreadcrumbs([queries.join(', ')])} + pageHeader={{ + pageTitle: ( + <> + {activeQuery}{' '} + <EuiBadge iconType={AutomatedIcon} color="accent"> + {AUTOMATED_LABEL} + </EuiBadge> + </> + ), + rightSideItems: [ + <EuiButton + color="primary" + fill + iconType="exportAction" + onClick={() => { + if (window.confirm(CONVERT_TO_MANUAL_CONFIRMATION)) convertToManual(); + }} + > + {COVERT_TO_MANUAL_BUTTON_LABEL} + </EuiButton>, + ], + }} + isLoading={dataLoading} + > + <PromotedDocuments /> + <EuiSpacer /> + <OrganicDocuments /> + </AppSearchPageTemplate> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx index 2efe1f2ffe86f..62c3a6c7d4578 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx @@ -12,26 +12,25 @@ import '../../../__mocks__/engine_logic.mock'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; -import { rerender, getPageTitle, getPageHeaderActions } from '../../../../test_helpers'; +import { rerender } from '../../../../test_helpers'; jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); -import { CurationLogic } from './curation_logic'; -import { AddResultFlyout } from './results'; +import { AutomatedCuration } from './automated_curation'; + +import { ManualCuration } from './manual_curation'; import { Curation } from './'; describe('Curation', () => { const values = { - dataLoading: false, - queries: ['query A', 'query B'], - isFlyoutOpen: false, + isAutomated: true, }; + const actions = { loadCuration: jest.fn(), - resetCuration: jest.fn(), }; beforeEach(() => { @@ -40,32 +39,6 @@ describe('Curation', () => { setMockActions(actions); }); - it('renders', () => { - const wrapper = shallow(<Curation />); - - expect(getPageTitle(wrapper)).toEqual('Manage curation'); - expect(wrapper.prop('pageChrome')).toEqual([ - 'Engines', - 'some-engine', - 'Curations', - 'query A, query B', - ]); - }); - - it('renders the add result flyout when open', () => { - setMockValues({ ...values, isFlyoutOpen: true }); - const wrapper = shallow(<Curation />); - - expect(wrapper.find(AddResultFlyout)).toHaveLength(1); - }); - - it('initializes CurationLogic with a curationId prop from URL param', () => { - mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' }); - shallow(<Curation />); - - expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' }); - }); - it('calls loadCuration on page load & whenever the curationId URL param changes', () => { mockUseParams.mockReturnValueOnce({ curationId: 'cur-123456789' }); const wrapper = shallow(<Curation />); @@ -76,31 +49,17 @@ describe('Curation', () => { expect(actions.loadCuration).toHaveBeenCalledTimes(2); }); - describe('restore defaults button', () => { - let restoreDefaultsButton: ShallowWrapper; - let confirmSpy: jest.SpyInstance; - - beforeAll(() => { - const wrapper = shallow(<Curation />); - restoreDefaultsButton = getPageHeaderActions(wrapper).childAt(0); - - confirmSpy = jest.spyOn(window, 'confirm'); - }); + it('renders a view for automated curations', () => { + setMockValues({ isAutomated: true }); + const wrapper = shallow(<Curation />); - afterAll(() => { - confirmSpy.mockRestore(); - }); + expect(wrapper.is(AutomatedCuration)).toBe(true); + }); - it('resets the curation upon user confirmation', () => { - confirmSpy.mockReturnValueOnce(true); - restoreDefaultsButton.simulate('click'); - expect(actions.resetCuration).toHaveBeenCalled(); - }); + it('renders a view for manual curations', () => { + setMockValues({ isAutomated: false }); + const wrapper = shallow(<Curation />); - it('does not reset the curation if the user cancels', () => { - confirmSpy.mockReturnValueOnce(false); - restoreDefaultsButton.simulate('click'); - expect(actions.resetCuration).not.toHaveBeenCalled(); - }); + expect(wrapper.is(ManualCuration)).toBe(true); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx index 2a01c0db049ab..19b6542e96c4b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx @@ -10,64 +10,18 @@ import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; -import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; - -import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../../constants'; -import { AppSearchPageTemplate } from '../../layout'; -import { MANAGE_CURATION_TITLE, RESTORE_CONFIRMATION } from '../constants'; -import { getCurationsBreadcrumbs } from '../utils'; - +import { AutomatedCuration } from './automated_curation'; import { CurationLogic } from './curation_logic'; -import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents'; -import { ActiveQuerySelect, ManageQueriesModal } from './queries'; -import { AddResultLogic, AddResultFlyout } from './results'; +import { ManualCuration } from './manual_curation'; export const Curation: React.FC = () => { const { curationId } = useParams() as { curationId: string }; - const { loadCuration, resetCuration } = useActions(CurationLogic({ curationId })); - const { dataLoading, queries } = useValues(CurationLogic({ curationId })); - const { isFlyoutOpen } = useValues(AddResultLogic); + const { loadCuration } = useActions(CurationLogic({ curationId })); + const { isAutomated } = useValues(CurationLogic({ curationId })); useEffect(() => { loadCuration(); }, [curationId]); - return ( - <AppSearchPageTemplate - pageChrome={getCurationsBreadcrumbs([queries.join(', ')])} - pageHeader={{ - pageTitle: MANAGE_CURATION_TITLE, - rightSideItems: [ - <EuiButton - color="danger" - onClick={() => { - if (window.confirm(RESTORE_CONFIRMATION)) resetCuration(); - }} - > - {RESTORE_DEFAULTS_BUTTON_LABEL} - </EuiButton>, - ], - }} - isLoading={dataLoading} - > - <EuiFlexGroup alignItems="flexEnd" gutterSize="xl" responsive={false}> - <EuiFlexItem> - <ActiveQuerySelect /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <ManageQueriesModal /> - </EuiFlexItem> - </EuiFlexGroup> - - <EuiSpacer size="xl" /> - - <PromotedDocuments /> - <EuiSpacer /> - <OrganicDocuments /> - <EuiSpacer /> - <HiddenDocuments /> - - {isFlyoutOpen && <AddResultFlyout />} - </AppSearchPageTemplate> - ); + return isAutomated ? <AutomatedCuration /> : <ManualCuration />; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.test.ts index 8fa57e52a26a1..941fd0bf28f96 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.test.ts @@ -55,6 +55,7 @@ describe('CurationLogic', () => { promotedDocumentsLoading: false, hiddenIds: [], hiddenDocumentsLoading: false, + isAutomated: false, }; beforeEach(() => { @@ -265,7 +266,60 @@ describe('CurationLogic', () => { }); }); + describe('selectors', () => { + describe('isAutomated', () => { + it('is true when suggestion status is automated', () => { + mount({ curation: { suggestion: { status: 'automated' } } }); + + expect(CurationLogic.values.isAutomated).toBe(true); + }); + + it('is false when suggestion status is not automated', () => { + for (status of ['pending', 'applied', 'rejected', 'disabled']) { + mount({ curation: { suggestion: { status } } }); + + expect(CurationLogic.values.isAutomated).toBe(false); + } + }); + }); + }); + describe('listeners', () => { + describe('convertToManual', () => { + it('should make an API call and re-load the curation on success', async () => { + http.put.mockReturnValueOnce(Promise.resolve()); + mount({ activeQuery: 'some query' }); + jest.spyOn(CurationLogic.actions, 'loadCuration'); + + CurationLogic.actions.convertToManual(); + await nextTick(); + + expect(http.put).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/search_relevance_suggestions', + { + body: JSON.stringify([ + { + query: 'some query', + type: 'curation', + status: 'applied', + }, + ]), + } + ); + expect(CurationLogic.actions.loadCuration).toHaveBeenCalled(); + }); + + it('flashes any error messages', async () => { + http.put.mockReturnValueOnce(Promise.reject('error')); + mount({ activeQuery: 'some query' }); + + CurationLogic.actions.convertToManual(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + describe('loadCuration', () => { it('should set dataLoading state', () => { mount({ dataLoading: false }, { curationId: 'cur-123456789' }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.ts index c49fc76d06874..a9fa5ab8c1048 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation_logic.ts @@ -27,9 +27,11 @@ interface CurationValues { promotedDocumentsLoading: boolean; hiddenIds: string[]; hiddenDocumentsLoading: boolean; + isAutomated: boolean; } interface CurationActions { + convertToManual(): void; loadCuration(): void; onCurationLoad(curation: Curation): { curation: Curation }; updateCuration(): void; @@ -53,6 +55,7 @@ interface CurationProps { export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions, CurationProps>>({ path: ['enterprise_search', 'app_search', 'curation_logic'], actions: () => ({ + convertToManual: true, loadCuration: true, onCurationLoad: (curation) => ({ curation }), updateCuration: true, @@ -162,7 +165,34 @@ export const CurationLogic = kea<MakeLogicType<CurationValues, CurationActions, }, ], }), + selectors: ({ selectors }) => ({ + isAutomated: [ + () => [selectors.curation], + (curation: CurationValues['curation']) => { + return curation.suggestion?.status === 'automated'; + }, + ], + }), listeners: ({ actions, values, props }) => ({ + convertToManual: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + await http.put(`/internal/app_search/engines/${engineName}/search_relevance_suggestions`, { + body: JSON.stringify([ + { + query: values.activeQuery, + type: 'curation', + status: 'applied', + }, + ]), + }); + actions.loadCuration(); + } catch (e) { + flashAPIErrors(e); + } + }, loadCuration: async () => { const { http } = HttpLogic.values; const { engineName } = EngineLogic.values; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.test.tsx index 0624d0063e57d..b7955cf514079 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.test.tsx @@ -13,6 +13,8 @@ import { shallow } from 'enzyme'; import { EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui'; +import { mountWithIntl } from '../../../../../test_helpers'; + import { DataPanel } from '../../../data_panel'; import { CurationResult } from '../results'; @@ -30,6 +32,7 @@ describe('OrganicDocuments', () => { }, activeQuery: 'world', organicDocumentsLoading: false, + isAutomated: false, }; const actions = { addPromotedId: jest.fn(), @@ -56,6 +59,13 @@ describe('OrganicDocuments', () => { expect(titleText).toEqual('Top organic documents for "world"'); }); + it('shows a title when the curation is manual', () => { + setMockValues({ ...values, isAutomated: false }); + const wrapper = shallow(<OrganicDocuments />); + + expect(wrapper.find(DataPanel).prop('subtitle')).toContain('Promote results'); + }); + it('renders a loading state', () => { setMockValues({ ...values, organicDocumentsLoading: true }); const wrapper = shallow(<OrganicDocuments />); @@ -63,11 +73,21 @@ describe('OrganicDocuments', () => { expect(wrapper.find(EuiLoadingContent)).toHaveLength(1); }); - it('renders an empty state', () => { - setMockValues({ ...values, curation: { organic: [] } }); - const wrapper = shallow(<OrganicDocuments />); + describe('empty state', () => { + it('renders', () => { + setMockValues({ ...values, curation: { organic: [] } }); + const wrapper = shallow(<OrganicDocuments />); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + it('tells the user to modify the query if the curation is manual', () => { + setMockValues({ ...values, curation: { organic: [] }, isAutomated: false }); + const wrapper = shallow(<OrganicDocuments />); + const emptyPromptBody = mountWithIntl(<>{wrapper.find(EuiEmptyPrompt).prop('body')}</>); + + expect(emptyPromptBody.text()).toContain('Add or change'); + }); }); describe('actions', () => { @@ -86,5 +106,13 @@ describe('OrganicDocuments', () => { expect(actions.addHiddenId).toHaveBeenCalledWith('mock-document-3'); }); + + it('hides actions when the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(<OrganicDocuments />); + const result = wrapper.find(CurationResult).first(); + + expect(result.prop('actions')).toEqual([]); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.tsx index a3a761feefcd2..7314376a4a7ab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/organic_documents.tsx @@ -11,6 +11,7 @@ import { useValues, useActions } from 'kea'; import { EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DataPanel } from '../../../data_panel'; import { Result } from '../../../result/types'; @@ -25,7 +26,7 @@ import { CurationResult } from '../results'; export const OrganicDocuments: React.FC = () => { const { addPromotedId, addHiddenId } = useActions(CurationLogic); - const { curation, activeQuery, organicDocumentsLoading } = useValues(CurationLogic); + const { curation, activeQuery, isAutomated, organicDocumentsLoading } = useValues(CurationLogic); const documents = curation.organic; const hasDocuments = documents.length > 0 && !organicDocumentsLoading; @@ -46,36 +47,50 @@ export const OrganicDocuments: React.FC = () => { )} </h2> } - subtitle={RESULT_ACTIONS_DIRECTIONS} + subtitle={!isAutomated && RESULT_ACTIONS_DIRECTIONS} > {hasDocuments ? ( documents.map((document: Result) => ( <CurationResult result={document} key={document.id.raw} - actions={[ - { - ...HIDE_DOCUMENT_ACTION, - onClick: () => addHiddenId(document.id.raw), - }, - { - ...PROMOTE_DOCUMENT_ACTION, - onClick: () => addPromotedId(document.id.raw), - }, - ]} + actions={ + isAutomated + ? [] + : [ + { + ...HIDE_DOCUMENT_ACTION, + onClick: () => addHiddenId(document.id.raw), + }, + { + ...PROMOTE_DOCUMENT_ACTION, + onClick: () => addPromotedId(document.id.raw), + }, + ] + } /> )) ) : organicDocumentsLoading ? ( <EuiLoadingContent lines={5} /> ) : ( <EuiEmptyPrompt - body={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.emptyDescription', - { - defaultMessage: - 'No organic results to display. Add or change the active query above.', - } - )} + body={ + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.description" + defaultMessage="No organic results to display.{manualDescription}" + values={{ + manualDescription: !isAutomated && ( + <> + {' '} + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.manualDescription" + defaultMessage="Add or change the active query above." + /> + </> + ), + }} + /> + } /> )} </DataPanel> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.test.tsx index e0c6de973666c..a66b33a47f35c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.test.tsx @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { setMockValues, setMockActions } from '../../../../../__mocks__/kea_logic'; import React from 'react'; @@ -13,6 +12,7 @@ import { shallow } from 'enzyme'; import { EuiDragDropContext, EuiDraggable, EuiEmptyPrompt, EuiButtonEmpty } from '@elastic/eui'; +import { mountWithIntl } from '../../../../../test_helpers'; import { DataPanel } from '../../../data_panel'; import { CurationResult } from '../results'; @@ -57,11 +57,50 @@ describe('PromotedDocuments', () => { }); }); - it('renders an empty state & hides the panel actions when empty', () => { + it('informs the user documents can be re-ordered if the curation is manual', () => { + setMockValues({ ...values, isAutomated: false }); + const wrapper = shallow(<PromotedDocuments />); + const subtitle = mountWithIntl(wrapper.prop('subtitle')); + + expect(subtitle.text()).toContain('Documents can be re-ordered'); + }); + + it('informs the user the curation is managed if the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(<PromotedDocuments />); + const subtitle = mountWithIntl(wrapper.prop('subtitle')); + + expect(subtitle.text()).toContain('managed by App Search'); + }); + + describe('empty state', () => { + it('renders', () => { + setMockValues({ ...values, curation: { promoted: [] } }); + const wrapper = shallow(<PromotedDocuments />); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('hide information about starring documents if the curation is automated', () => { + setMockValues({ ...values, curation: { promoted: [] }, isAutomated: true }); + const wrapper = shallow(<PromotedDocuments />); + const emptyPromptBody = mountWithIntl(<>{wrapper.find(EuiEmptyPrompt).prop('body')}</>); + + expect(emptyPromptBody.text()).not.toContain('Star documents'); + }); + }); + + it('hides the panel actions when empty', () => { setMockValues({ ...values, curation: { promoted: [] } }); const wrapper = shallow(<PromotedDocuments />); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(DataPanel).prop('action')).toBe(false); + }); + + it('hides the panel actions when the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(<PromotedDocuments />); + expect(wrapper.find(DataPanel).prop('action')).toBe(false); }); @@ -81,6 +120,14 @@ describe('PromotedDocuments', () => { expect(actions.removePromotedId).toHaveBeenCalledWith('mock-document-4'); }); + it('hides demote button for results when the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(<PromotedDocuments />); + const result = getDraggableChildren(wrapper.find(EuiDraggable).last()); + + expect(result.prop('actions')).toEqual([]); + }); + it('renders a demote all button that demotes all hidden results', () => { const wrapper = shallow(<PromotedDocuments />); const panelActions = shallow(wrapper.find(DataPanel).prop('action') as React.ReactElement); @@ -89,7 +136,7 @@ describe('PromotedDocuments', () => { expect(actions.clearPromotedIds).toHaveBeenCalled(); }); - describe('draggging', () => { + describe('dragging', () => { it('calls setPromotedIds with the reordered list when users are done dragging', () => { const wrapper = shallow(<PromotedDocuments />); wrapper.find(EuiDragDropContext).simulate('dragEnd', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.tsx index 6b0a02aa2af58..e9d9136a45ac6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/promoted_documents.tsx @@ -21,6 +21,7 @@ import { euiDragDropReorder, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DataPanel } from '../../../data_panel'; @@ -29,7 +30,7 @@ import { CurationLogic } from '../curation_logic'; import { AddResultButton, CurationResult, convertToResultFormat } from '../results'; export const PromotedDocuments: React.FC = () => { - const { curation, promotedIds, promotedDocumentsLoading } = useValues(CurationLogic); + const { curation, isAutomated, promotedIds, promotedDocumentsLoading } = useValues(CurationLogic); const documents = curation.promoted; const hasDocuments = documents.length > 0; @@ -53,21 +54,33 @@ export const PromotedDocuments: React.FC = () => { )} </h2> } - subtitle={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.description', - { - defaultMessage: - 'Promoted results appear before organic results. Documents can be re-ordered.', - } - )} + subtitle={ + isAutomated ? ( + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.automatedDescription" + defaultMessage="This curation is being managed by App Search" + /> + ) : ( + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.manualDescription" + defaultMessage="Promoted results appear before organic results. Documents can be re-ordered." + /> + ) + } action={ + !isAutomated && hasDocuments && ( <EuiFlexGroup gutterSize="s" responsive={false} wrap> <EuiFlexItem> <AddResultButton /> </EuiFlexItem> <EuiFlexItem> - <EuiButtonEmpty onClick={clearPromotedIds} iconType="menuDown" size="s"> + <EuiButtonEmpty + onClick={clearPromotedIds} + iconType="menuDown" + size="s" + disabled={isAutomated} + > {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel', { defaultMessage: 'Demote all' } @@ -89,17 +102,22 @@ export const PromotedDocuments: React.FC = () => { draggableId={document.id} customDragHandle spacing="none" + isDragDisabled={isAutomated} > {(provided) => ( <CurationResult key={document.id} result={convertToResultFormat(document)} - actions={[ - { - ...DEMOTE_DOCUMENT_ACTION, - onClick: () => removePromotedId(document.id), - }, - ]} + actions={ + isAutomated + ? [] + : [ + { + ...DEMOTE_DOCUMENT_ACTION, + onClick: () => removePromotedId(document.id), + }, + ] + } dragHandleProps={provided.dragHandleProps} /> )} @@ -109,13 +127,22 @@ export const PromotedDocuments: React.FC = () => { </EuiDragDropContext> ) : ( <EuiEmptyPrompt - body={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.emptyDescription', - { - defaultMessage: - 'Star documents from the organic results below, or search and promote a result manually.', - } - )} + body={ + isAutomated + ? i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.automatedEmptyDescription', + { + defaultMessage: "We haven't identified any documents to promote", + } + ) + : i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.emptyDescription', + { + defaultMessage: + 'Star documents from the organic results below, or search and promote a result manually.', + } + ) + } actions={<AddResultButton />} /> )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.tsx new file mode 100644 index 0000000000000..32b9e91c8d6f2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.test.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 '../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../__mocks__/react_router'; +import '../../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { getPageTitle, getPageHeaderActions } from '../../../../test_helpers'; + +jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); +import { CurationLogic } from './curation_logic'; + +import { ManualCuration } from './manual_curation'; +import { AddResultFlyout } from './results'; +import { SuggestedDocumentsCallout } from './suggested_documents_callout'; + +describe('ManualCuration', () => { + const values = { + dataLoading: false, + queries: ['query A', 'query B'], + isFlyoutOpen: false, + }; + const actions = { + resetCuration: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(<ManualCuration />); + + expect(getPageTitle(wrapper)).toEqual('Manage curation'); + expect(wrapper.prop('pageChrome')).toEqual([ + 'Engines', + 'some-engine', + 'Curations', + 'query A, query B', + ]); + }); + + it('contains a suggested documents callout', () => { + const wrapper = shallow(<ManualCuration />); + + expect(wrapper.find(SuggestedDocumentsCallout)).toHaveLength(1); + }); + + it('renders the add result flyout when open', () => { + setMockValues({ ...values, isFlyoutOpen: true }); + const wrapper = shallow(<ManualCuration />); + + expect(wrapper.find(AddResultFlyout)).toHaveLength(1); + }); + + it('initializes CurationLogic with a curationId prop from URL param', () => { + mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' }); + shallow(<ManualCuration />); + + expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' }); + }); + + describe('restore defaults button', () => { + let restoreDefaultsButton: ShallowWrapper; + let confirmSpy: jest.SpyInstance; + + beforeAll(() => { + const wrapper = shallow(<ManualCuration />); + restoreDefaultsButton = getPageHeaderActions(wrapper).childAt(0); + + confirmSpy = jest.spyOn(window, 'confirm'); + }); + + afterAll(() => { + confirmSpy.mockRestore(); + }); + + it('resets the curation upon user confirmation', () => { + confirmSpy.mockReturnValueOnce(true); + restoreDefaultsButton.simulate('click'); + expect(actions.resetCuration).toHaveBeenCalled(); + }); + + it('does not reset the curation if the user cancels', () => { + confirmSpy.mockReturnValueOnce(false); + restoreDefaultsButton.simulate('click'); + expect(actions.resetCuration).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx new file mode 100644 index 0000000000000..1482858801d2b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/manual_curation.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { useValues, useActions } from 'kea'; + +import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; + +import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../../constants'; +import { AppSearchPageTemplate } from '../../layout'; +import { MANAGE_CURATION_TITLE, RESTORE_CONFIRMATION } from '../constants'; +import { getCurationsBreadcrumbs } from '../utils'; + +import { CurationLogic } from './curation_logic'; +import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents'; +import { ActiveQuerySelect, ManageQueriesModal } from './queries'; +import { AddResultLogic, AddResultFlyout } from './results'; +import { SuggestedDocumentsCallout } from './suggested_documents_callout'; + +export const ManualCuration: React.FC = () => { + const { curationId } = useParams() as { curationId: string }; + const { resetCuration } = useActions(CurationLogic({ curationId })); + const { dataLoading, queries } = useValues(CurationLogic({ curationId })); + const { isFlyoutOpen } = useValues(AddResultLogic); + + return ( + <AppSearchPageTemplate + pageChrome={getCurationsBreadcrumbs([queries.join(', ')])} + pageHeader={{ + pageTitle: MANAGE_CURATION_TITLE, + rightSideItems: [ + <EuiButton + color="danger" + onClick={() => { + if (window.confirm(RESTORE_CONFIRMATION)) resetCuration(); + }} + > + {RESTORE_DEFAULTS_BUTTON_LABEL} + </EuiButton>, + ], + }} + isLoading={dataLoading} + > + <SuggestedDocumentsCallout /> + <EuiFlexGroup alignItems="flexEnd" gutterSize="xl" responsive={false}> + <EuiFlexItem> + <ActiveQuerySelect /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <ManageQueriesModal /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="xl" /> + + <PromotedDocuments /> + <EuiSpacer /> + <OrganicDocuments /> + <EuiSpacer /> + <HiddenDocuments /> + + {isFlyoutOpen && <AddResultFlyout />} + </AppSearchPageTemplate> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.test.tsx index 53cefdd00c670..5b5c814a24c5b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.test.tsx @@ -5,34 +5,43 @@ * 2.0. */ -import { setMockActions } from '../../../../../__mocks__/kea_logic'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; import { EuiButton } from '@elastic/eui'; import { AddResultButton } from './'; describe('AddResultButton', () => { + const values = { + isAutomated: false, + }; + const actions = { openFlyout: jest.fn(), }; - let wrapper: ShallowWrapper; - - beforeAll(() => { - setMockActions(actions); - wrapper = shallow(<AddResultButton />); - }); - it('renders', () => { - expect(wrapper.find(EuiButton)).toHaveLength(1); + const wrapper = shallow(<AddResultButton />); + + expect(wrapper.is(EuiButton)).toBe(true); }); it('opens the add result flyout on click', () => { + setMockActions(actions); + const wrapper = shallow(<AddResultButton />); + wrapper.find(EuiButton).simulate('click'); expect(actions.openFlyout).toHaveBeenCalled(); }); + + it('is disbled when the curation is automated', () => { + setMockValues({ ...values, isAutomated: true }); + const wrapper = shallow(<AddResultButton />); + + expect(wrapper.find(EuiButton).prop('disabled')).toBe(true); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.tsx index 025dda65f4fb8..f2285064da307 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_button.tsx @@ -7,18 +7,21 @@ import React from 'react'; -import { useActions } from 'kea'; +import { useActions, useValues } from 'kea'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { CurationLogic } from '..'; + import { AddResultLogic } from './'; export const AddResultButton: React.FC = () => { const { openFlyout } = useActions(AddResultLogic); + const { isAutomated } = useValues(CurationLogic); return ( - <EuiButton onClick={openFlyout} iconType="plusInCircle" size="s" fill> + <EuiButton onClick={openFlyout} iconType="plusInCircle" size="s" fill disabled={isAutomated}> {i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addResult.buttonLabel', { defaultMessage: 'Add result manually', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.test.tsx new file mode 100644 index 0000000000000..29418d09218f4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.test.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 '../../../__mocks__/engine_logic.mock'; + +import { setMockValues } from '../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; +import { set } from 'lodash/fp'; + +import { SuggestionsCallout } from '../components/suggestions_callout'; + +import { SuggestedDocumentsCallout } from './suggested_documents_callout'; + +const MOCK_VALUES = { + // CurationLogic + curation: { + suggestion: { + status: 'pending', + updated_at: '2021-01-01T00:30:00Z', + }, + queries: ['some query'], + }, +}; + +describe('SuggestedDocumentsCallout', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(MOCK_VALUES); + }); + + it('renders', () => { + const wrapper = shallow(<SuggestedDocumentsCallout />); + + expect(wrapper.is(SuggestionsCallout)); + }); + + it('is empty when the suggested is undefined', () => { + setMockValues({ ...MOCK_VALUES, curation: {} }); + + const wrapper = shallow(<SuggestedDocumentsCallout />); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('is empty when curation status is not pending', () => { + const values = set('curation.suggestion.status', 'applied', MOCK_VALUES); + setMockValues(values); + const wrapper = shallow(<SuggestedDocumentsCallout />); + + expect(wrapper.isEmptyRender()).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.tsx new file mode 100644 index 0000000000000..e443e77d76190 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/suggested_documents_callout.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { ENGINE_CURATION_SUGGESTION_PATH } from '../../../routes'; +import { generateEnginePath } from '../../engine'; + +import { SuggestionsCallout } from '../components/suggestions_callout'; + +import { CurationLogic } from '.'; + +export const SuggestedDocumentsCallout: React.FC = () => { + const { + curation: { suggestion, queries }, + } = useValues(CurationLogic); + + if (typeof suggestion === 'undefined' || suggestion.status !== 'pending') { + return null; + } + + return ( + <SuggestionsCallout + title={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curation.suggestedDocumentsCallout.title', + { defaultMessage: 'New suggested documents for this query' } + )} + description={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curation.suggestedDocumentsCallout.description', + { + defaultMessage: + "Based on your engine's analytics, there are new suggested document promotions ready to review.", + } + )} + buttonTo={generateEnginePath(ENGINE_CURATION_SUGGESTION_PATH, { + query: queries[0], + })} + lastUpdatedTimestamp={suggestion.updated_at} + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts index 866bf6490ebe8..96e300e0d0520 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts @@ -12,7 +12,11 @@ export interface CurationSuggestion { query: string; updated_at: string; promoted: string[]; + status: 'pending' | 'applied' | 'automated' | 'rejected' | 'disabled'; + curation_id?: string; + override_curation_id?: string; } + export interface Curation { id: string; last_updated: string; @@ -20,6 +24,7 @@ export interface Curation { promoted: CurationResult[]; hidden: CurationResult[]; organic: Result[]; + suggestion?: CurationSuggestion; } export interface CurationsAPIResponse { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_result_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_result_panel.tsx index b61355d0b8555..5a21bfcb38843 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_result_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_result_panel.tsx @@ -73,7 +73,7 @@ export const CurationResultPanel: React.FC<Props> = ({ variant, results }) => { > {results.length > 0 ? ( results.map((result) => ( - <EuiFlexItem grow={false} key={result.id.raw}> + <EuiFlexItem key={result.id.raw} style={{ width: '100%' }}> <Result result={result} isMetaEngine={isMetaEngine} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.test.tsx index 2dcefa7273c72..1c3f4645d89e9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.test.tsx @@ -57,6 +57,14 @@ describe('CurationSuggestion', () => { }, }, ], + curation: { + promoted: [ + { + id: '4', + foo: 'foo', + }, + ], + }, isMetaEngine: true, engine: { schema: {}, @@ -88,6 +96,27 @@ describe('CurationSuggestion', () => { expect(actions.loadSuggestion).toHaveBeenCalled(); }); + it('shows existing promoted documents', () => { + const wrapper = shallow(<CurationSuggestion />); + const suggestedResultsPanel = wrapper.find(CurationResultPanel).at(0); + // gets populated from 'curation' in state, and converted to results format (i.e, has raw properties, etc.) + expect(suggestedResultsPanel.prop('results')).toEqual([ + { + id: { + raw: '4', + snippet: null, + }, + foo: { + raw: 'foo', + snippet: null, + }, + _meta: { + id: '4', + }, + }, + ]); + }); + it('shows suggested promoted documents', () => { const wrapper = shallow(<CurationSuggestion />); const suggestedResultsPanel = wrapper.find(CurationResultPanel).at(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.tsx index ade78e4914e84..3191d4e912cff 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.tsx @@ -25,6 +25,7 @@ import { EngineLogic } from '../../../engine'; import { AppSearchPageTemplate } from '../../../layout'; import { Result } from '../../../result'; import { Result as ResultType } from '../../../result/types'; +import { convertToResultFormat } from '../../curation/results'; import { getCurationsBreadcrumbs } from '../../utils'; import { CurationActionBar } from './curation_action_bar'; @@ -35,14 +36,15 @@ import { DATA } from './temp_data'; export const CurationSuggestion: React.FC = () => { const { query } = useDecodedParams(); + const { engine, isMetaEngine } = useValues(EngineLogic); const curationSuggestionLogic = CurationSuggestionLogic({ query }); const { loadSuggestion } = useActions(curationSuggestionLogic); - const { engine, isMetaEngine } = useValues(EngineLogic); - const { suggestion, suggestedPromotedDocuments, dataLoading } = + const { suggestion, suggestedPromotedDocuments, curation, dataLoading } = useValues(curationSuggestionLogic); const [showOrganicResults, setShowOrganicResults] = useState(false); const currentOrganicResults = [...DATA].splice(5, 4); const proposedOrganicResults = [...DATA].splice(2, 4); + const existingCurationResults = curation ? curation.promoted.map(convertToResultFormat) : []; const suggestionQuery = suggestion?.query || ''; @@ -79,7 +81,7 @@ export const CurationSuggestion: React.FC = () => { </h2> </EuiTitle> <EuiSpacer size="s" /> - <CurationResultPanel variant="current" results={[...DATA].splice(0, 3)} /> + <CurationResultPanel variant="current" results={existingCurationResults} /> </EuiFlexItem> <EuiFlexItem> <EuiTitle size="xxs"> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts index 6e616dcd9452c..af694c3756fd1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts @@ -11,22 +11,41 @@ import { mockHttpValues, } from '../../../../../__mocks__/kea_logic'; +import { set } from 'lodash/fp'; + import '../../../../__mocks__/engine_logic.mock'; import { nextTick } from '@kbn/test/jest'; +import { CurationSuggestion } from '../../types'; + import { CurationSuggestionLogic } from './curation_suggestion_logic'; const DEFAULT_VALUES = { dataLoading: true, suggestion: null, suggestedPromotedDocuments: [], + curation: null, }; -const suggestion = { +const suggestion: CurationSuggestion = { query: 'foo', updated_at: '2021-07-08T14:35:50Z', promoted: ['1', '2', '3'], + status: 'applied', +}; + +const curation = { + id: 'cur-6155e69c7a2f2e4f756303fd', + queries: ['foo'], + promoted: [ + { + id: '5', + }, + ], + hidden: [], + last_updated: 'September 30, 2021 at 04:32PM', + organic: [], }; const suggestedPromotedDocuments = [ @@ -114,16 +133,18 @@ describe('CurationSuggestionLogic', () => { describe('actions', () => { describe('onSuggestionLoaded', () => { - it('should save the loaded suggestion and promoted documents associated with that suggestion and set dataLoading to false', () => { + it('should save provided state and set dataLoading to false', () => { mountLogic(); CurationSuggestionLogic.actions.onSuggestionLoaded({ suggestion, suggestedPromotedDocuments, + curation, }); expect(CurationSuggestionLogic.values).toEqual({ ...DEFAULT_VALUES, suggestion, suggestedPromotedDocuments, + curation, dataLoading: false, }); }); @@ -143,7 +164,7 @@ describe('CurationSuggestionLogic', () => { }); }); - it('should make an API call and trigger onSuggestionLoaded', async () => { + it('should make API calls to fetch data and trigger onSuggestionLoaded', async () => { http.post.mockReturnValueOnce(Promise.resolve(MOCK_RESPONSE)); http.post.mockReturnValueOnce(Promise.resolve(MOCK_DOCUMENTS_RESPONSE)); mountLogic(); @@ -186,6 +207,7 @@ describe('CurationSuggestionLogic', () => { query: 'foo', updated_at: '2021-07-08T14:35:50Z', promoted: ['1', '2', '3'], + status: 'applied', }, // Note that these were re-ordered to match the 'promoted' list above, and since document // 3 was not found it is not included in this list @@ -209,6 +231,36 @@ describe('CurationSuggestionLogic', () => { }, }, ], + curation: null, + }); + }); + + it('will also fetch curation details if the suggestion has a curation_id', async () => { + http.post.mockReturnValueOnce( + Promise.resolve( + set('results[0].curation_id', 'cur-6155e69c7a2f2e4f756303fd', MOCK_RESPONSE) + ) + ); + http.post.mockReturnValueOnce(Promise.resolve(MOCK_DOCUMENTS_RESPONSE)); + http.get.mockReturnValueOnce(Promise.resolve(curation)); + mountLogic({ + suggestion: set('curation_id', 'cur-6155e69c7a2f2e4f756303fd', suggestion), + }); + jest.spyOn(CurationSuggestionLogic.actions, 'onSuggestionLoaded'); + + CurationSuggestionLogic.actions.loadSuggestion(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/curations/cur-6155e69c7a2f2e4f756303fd', + { query: { skip_record_analytics: 'true' } } + ); + await nextTick(); + + expect(CurationSuggestionLogic.actions.onSuggestionLoaded).toHaveBeenCalledWith({ + suggestion: expect.any(Object), + suggestedPromotedDocuments: expect.any(Object), + curation, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts index d3f27be122060..3c3af8bfd96c8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts @@ -6,17 +6,19 @@ */ import { kea, MakeLogicType } from 'kea'; +import { HttpSetup } from 'kibana/public'; import { flashAPIErrors } from '../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../shared/http'; import { EngineLogic } from '../../../engine'; import { Result } from '../../../result/types'; -import { CurationSuggestion } from '../../types'; +import { Curation, CurationSuggestion } from '../../types'; interface CurationSuggestionValues { dataLoading: boolean; suggestion: CurationSuggestion | null; suggestedPromotedDocuments: Result[]; + curation: Curation | null; } interface CurationSuggestionActions { @@ -24,12 +26,15 @@ interface CurationSuggestionActions { onSuggestionLoaded({ suggestion, suggestedPromotedDocuments, + curation, }: { suggestion: CurationSuggestion; suggestedPromotedDocuments: Result[]; + curation: Curation; }): { suggestion: CurationSuggestion; suggestedPromotedDocuments: Result[]; + curation: Curation; }; } @@ -43,9 +48,10 @@ export const CurationSuggestionLogic = kea< path: ['enterprise_search', 'app_search', 'curations', 'suggestion_logic'], actions: () => ({ loadSuggestion: true, - onSuggestionLoaded: ({ suggestion, suggestedPromotedDocuments }) => ({ + onSuggestionLoaded: ({ suggestion, suggestedPromotedDocuments, curation }) => ({ suggestion, suggestedPromotedDocuments, + curation, }), }), reducers: () => ({ @@ -68,6 +74,12 @@ export const CurationSuggestionLogic = kea< onSuggestionLoaded: (_, { suggestedPromotedDocuments }) => suggestedPromotedDocuments, }, ], + curation: [ + null, + { + onSuggestionLoaded: (_, { curation }) => curation, + }, + ], }), listeners: ({ actions, props }) => ({ loadSuggestion: async () => { @@ -75,44 +87,20 @@ export const CurationSuggestionLogic = kea< const { engineName } = EngineLogic.values; try { - const response = await http.post( - `/internal/app_search/engines/${engineName}/search_relevance_suggestions/${props.query}`, - { - body: JSON.stringify({ - page: { - current: 1, - size: 1, - }, - filters: { - status: ['pending'], - type: 'curation', - }, - }), - } - ); + const suggestion = await getSuggestions(http, engineName, props.query); + const promotedIds: string[] = suggestion.promoted; + const documentDetailsResopnse = getDocumentDetails(http, engineName, promotedIds); - const suggestion = response.results[0]; + let promises = [documentDetailsResopnse]; + if (suggestion.curation_id) { + promises = [...promises, getCuration(http, engineName, suggestion.curation_id)]; + } - const searchResponse = await http.post( - `/internal/app_search/engines/${engineName}/search`, - { - query: { query: '' }, - body: JSON.stringify({ - page: { - size: 100, - }, - filters: { - id: suggestion.promoted, - }, - }), - } - ); + const [documentDetails, curation] = await Promise.all(promises); // Filter out docs that were not found and maintain promoted order - const promotedIds: string[] = suggestion.promoted; - const documentDetails = searchResponse.results; const suggestedPromotedDocuments = promotedIds.reduce((acc: Result[], id: string) => { - const found = documentDetails.find( + const found = documentDetails.results.find( (documentDetail: Result) => documentDetail.id.raw === id ); if (!found) return acc; @@ -120,8 +108,9 @@ export const CurationSuggestionLogic = kea< }, []); actions.onSuggestionLoaded({ - suggestion: suggestion as CurationSuggestion, + suggestion, suggestedPromotedDocuments, + curation: curation || null, }); } catch (e) { flashAPIErrors(e); @@ -129,3 +118,48 @@ export const CurationSuggestionLogic = kea< }, }), }); + +const getSuggestions = async ( + http: HttpSetup, + engineName: string, + query: string +): Promise<CurationSuggestion> => { + const response = await http.post( + `/internal/app_search/engines/${engineName}/search_relevance_suggestions/${query}`, + { + body: JSON.stringify({ + page: { + current: 1, + size: 1, + }, + filters: { + status: ['pending'], + type: 'curation', + }, + }), + } + ); + + const suggestion = response.results[0] as CurationSuggestion; + return suggestion; +}; + +const getDocumentDetails = async (http: HttpSetup, engineName: string, documentIds: string[]) => { + return http.post(`/internal/app_search/engines/${engineName}/search`, { + query: { query: '' }, + body: JSON.stringify({ + page: { + size: 100, + }, + filters: { + id: documentIds, + }, + }), + }); +}; + +const getCuration = async (http: HttpSetup, engineName: string, curationId: string) => { + return http.get(`/internal/app_search/engines/${engineName}/curations/${curationId}`, { + query: { skip_record_analytics: 'true' }, + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts index 2c22a3addf63b..d4c652ab9c7a7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts @@ -22,6 +22,21 @@ export interface Engine { }; } +interface CurationSuggestionDetails { + count: number; + pending: number; + applied: number; + automated: number; + rejected: number; + disabled: number; + last_updated: string; +} + +interface SearchRelevanceSuggestionDetails { + count: number; + curation: CurationSuggestionDetails; +} + export interface EngineDetails extends Engine { created_at: string; document_count: number; @@ -38,6 +53,7 @@ export interface EngineDetails extends Engine { isMeta: boolean; engine_count?: number; includedEngines?: EngineDetails[]; + search_relevance_suggestions?: SearchRelevanceSuggestionDetails; } interface ResultField { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.test.tsx new file mode 100644 index 0000000000000..38e57fa0483e6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.test.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 '../../../__mocks__/engine_logic.mock'; + +import { setMockValues } from '../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; +import { set } from 'lodash/fp'; + +import { SuggestionsCallout } from '../../curations/components/suggestions_callout'; + +import { SuggestedCurationsCallout } from './suggested_curations_callout'; + +const MOCK_VALUES = { + // EngineLogic + engine: { + search_relevance_suggestions: { + curation: { + pending: 1, + }, + }, + }, +}; + +describe('SuggestedCurationsCallout', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(MOCK_VALUES); + }); + + it('renders', () => { + const wrapper = shallow(<SuggestedCurationsCallout />); + + expect(wrapper.is(SuggestionsCallout)); + }); + + it('is empty when the suggestions are undefined', () => { + setMockValues({ ...MOCK_VALUES, engine: {} }); + + const wrapper = shallow(<SuggestedCurationsCallout />); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('is empty when no pending curations', () => { + const values = set('engine.search_relevance_suggestions.curation.pending', 0, MOCK_VALUES); + setMockValues(values); + + const wrapper = shallow(<SuggestedCurationsCallout />); + + expect(wrapper.isEmptyRender()).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.tsx new file mode 100644 index 0000000000000..a7155b7d2b161 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/suggested_curations_callout.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { ENGINE_CURATIONS_PATH } from '../../../routes'; +import { SuggestionsCallout } from '../../curations/components/suggestions_callout'; +import { EngineLogic, generateEnginePath } from '../../engine'; + +export const SuggestedCurationsCallout: React.FC = () => { + const { + engine: { search_relevance_suggestions: searchRelevanceSuggestions }, + } = useValues(EngineLogic); + + const pendingCount = searchRelevanceSuggestions?.curation.pending; + + if (typeof searchRelevanceSuggestions === 'undefined' || pendingCount === 0) { + return null; + } + + return ( + <SuggestionsCallout + title={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.suggestedCurationsCallout.title', + { defaultMessage: 'New suggested curations to review' } + )} + description={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.suggestedCurationsCallout.description', + { + defaultMessage: + "Based on your engine's analytics, there are new suggested curations ready to review.", + } + )} + buttonTo={generateEnginePath(ENGINE_CURATIONS_PATH)} + lastUpdatedTimestamp={searchRelevanceSuggestions.curation.last_updated} + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx index 14f182463d837..c80e5c2208c31 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx @@ -16,6 +16,7 @@ import { shallow } from 'enzyme'; import { getPageTitle } from '../../../test_helpers'; import { TotalStats, TotalCharts, RecentApiLogs } from './components'; +import { SuggestedCurationsCallout } from './components/suggested_curations_callout'; import { EngineOverviewMetrics } from './engine_overview_metrics'; describe('EngineOverviewMetrics', () => { @@ -36,6 +37,7 @@ describe('EngineOverviewMetrics', () => { const wrapper = shallow(<EngineOverviewMetrics />); expect(getPageTitle(wrapper)).toEqual('Engine overview'); + expect(wrapper.find(SuggestedCurationsCallout)).toHaveLength(1); expect(wrapper.find(TotalStats)).toHaveLength(1); expect(wrapper.find(TotalCharts)).toHaveLength(1); expect(wrapper.find(RecentApiLogs)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx index 9c3a900dfe115..d245b293467f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx @@ -17,6 +17,8 @@ import { AppSearchPageTemplate } from '../layout'; import { TotalStats, TotalCharts, RecentApiLogs } from './components'; +import { SuggestedCurationsCallout } from './components/suggested_curations_callout'; + import { EngineOverviewLogic } from './'; export const EngineOverviewMetrics: React.FC = () => { @@ -38,6 +40,7 @@ export const EngineOverviewMetrics: React.FC = () => { isLoading={dataLoading} data-test-subj="EngineOverview" > + <SuggestedCurationsCallout /> <EuiFlexGroup> <EuiFlexItem grow={1}> <TotalStats /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx index 52fbee90fe31a..5eac38b88937c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_actions.tsx @@ -18,7 +18,7 @@ interface Props { export const ResultActions: React.FC<Props> = ({ actions }) => { return ( <EuiFlexGroup gutterSize="s" responsive={false}> - {actions.map(({ onClick, title, iconType, iconColor }) => ( + {actions.map(({ onClick, title, iconType, iconColor, disabled }) => ( <EuiFlexItem key={title} grow={false}> <EuiButtonIcon iconType={iconType} @@ -26,6 +26,7 @@ export const ResultActions: React.FC<Props> = ({ actions }) => { color={iconColor ? iconColor : 'primary'} aria-label={title} title={title} + disabled={disabled} /> </EuiFlexItem> ))} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts index 4be3eb137177b..d9f1bb394778e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts @@ -41,4 +41,5 @@ export interface ResultAction { title: string; iconType: string; iconColor?: EuiButtonIconColor; + disabled?: boolean; } 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 883c8631365eb..83cf21ce86233 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 @@ -105,84 +105,6 @@ describe('SourceSettings', () => { ); }); - it('handles disabling synchronization', () => { - const wrapper = shallow(<SourceSettings />); - - const synchronizeSwitch = wrapper.find('[data-test-subj="SynchronizeToggle"]').first(); - const event = { target: { checked: false } }; - synchronizeSwitch.prop('onChange')?.(event as any); - - wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click'); - - expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, { - indexing: { - enabled: false, - features: { - content_extraction: { enabled: true }, - thumbnails: { enabled: true }, - }, - }, - }); - }); - - it('handles disabling thumbnails', () => { - const wrapper = shallow(<SourceSettings />); - - const thumbnailsSwitch = wrapper.find('[data-test-subj="ThumbnailsToggle"]').first(); - const event = { target: { checked: false } }; - thumbnailsSwitch.prop('onChange')?.(event as any); - - wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click'); - - expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, { - indexing: { - enabled: true, - features: { - content_extraction: { enabled: true }, - thumbnails: { enabled: false }, - }, - }, - }); - }); - - it('handles disabling content extraction', () => { - const wrapper = shallow(<SourceSettings />); - - const contentExtractionSwitch = wrapper - .find('[data-test-subj="ContentExtractionToggle"]') - .first(); - const event = { target: { checked: false } }; - contentExtractionSwitch.prop('onChange')?.(event as any); - - wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click'); - - expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, { - indexing: { - enabled: true, - features: { - content_extraction: { enabled: false }, - thumbnails: { enabled: true }, - }, - }, - }); - }); - - it('disables the thumbnails switch when globally disabled', () => { - setMockValues({ - ...mockValues, - contentSource: { - ...fullContentSources[0], - areThumbnailsConfigEnabled: false, - }, - }); - - const wrapper = shallow(<SourceSettings />); - - const synchronizeSwitch = wrapper.find('[data-test-subj="ThumbnailsToggle"]'); - - expect(synchronizeSwitch.prop('disabled')).toEqual(true); - }); - describe('DownloadDiagnosticsButton', () => { it('renders for org with correct href', () => { const wrapper = shallow(<SourceSettings />); 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 585477fed058e..dd8625ebd7a7c 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 @@ -17,8 +17,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiSpacer, - EuiSwitch, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -51,12 +49,6 @@ import { SYNC_DIAGNOSTICS_TITLE, SYNC_DIAGNOSTICS_DESCRIPTION, SYNC_DIAGNOSTICS_BUTTON, - SYNC_MANAGEMENT_TITLE, - SYNC_MANAGEMENT_DESCRIPTION, - SYNC_MANAGEMENT_SYNCHRONIZE_LABEL, - SYNC_MANAGEMENT_THUMBNAILS_LABEL, - SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL, - SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL, } from '../constants'; import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; @@ -70,22 +62,7 @@ export const SourceSettings: React.FC = () => { const { getSourceConfigData } = useActions(AddSourceLogic); const { - contentSource: { - name, - id, - serviceType, - custom: isCustom, - isIndexedSource, - areThumbnailsConfigEnabled, - isOauth1, - indexing: { - enabled, - features: { - contentExtraction: { enabled: contentExtractionEnabled }, - thumbnails: { enabled: thumbnailsEnabled }, - }, - }, - }, + contentSource: { name, id, serviceType, isOauth1 }, buttonLoading, } = useValues(SourceLogic); @@ -109,11 +86,6 @@ export const SourceSettings: React.FC = () => { const hideConfirm = () => setModalVisibility(false); const showConfig = isOrganization && !isEmpty(configuredFields); - const showSyncControls = isOrganization && isIndexedSource && !isCustom; - - const [synchronizeChecked, setSynchronize] = useState(enabled); - const [thumbnailsChecked, setThumbnails] = useState(thumbnailsEnabled); - const [contentExtractionChecked, setContentExtraction] = useState(contentExtractionEnabled); const { clientId, clientSecret, publicKey, consumerKey, baseUrl } = configuredFields || {}; @@ -130,18 +102,6 @@ export const SourceSettings: React.FC = () => { updateContentSource(id, { name: inputValue }); }; - const submitSyncControls = () => { - updateContentSource(id, { - indexing: { - enabled: synchronizeChecked, - features: { - content_extraction: { enabled: contentExtractionChecked }, - thumbnails: { enabled: thumbnailsChecked }, - }, - }, - }); - }; - const handleSourceRemoval = () => { /** * The modal was just hanging while the UI waited for the server to respond. @@ -221,58 +181,6 @@ export const SourceSettings: React.FC = () => { </EuiFormRow> </ContentSection> )} - {showSyncControls && ( - <ContentSection title={SYNC_MANAGEMENT_TITLE} description={SYNC_MANAGEMENT_DESCRIPTION}> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiSwitch - checked={synchronizeChecked} - onChange={(e) => setSynchronize(e.target.checked)} - label={SYNC_MANAGEMENT_SYNCHRONIZE_LABEL} - data-test-subj="SynchronizeToggle" - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer /> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiSwitch - checked={thumbnailsChecked} - onChange={(e) => setThumbnails(e.target.checked)} - label={ - areThumbnailsConfigEnabled - ? SYNC_MANAGEMENT_THUMBNAILS_LABEL - : SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL - } - disabled={!areThumbnailsConfigEnabled} - data-test-subj="ThumbnailsToggle" - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiSwitch - checked={contentExtractionChecked} - onChange={(e) => setContentExtraction(e.target.checked)} - label={SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL} - data-test-subj="ContentExtractionToggle" - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer /> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiButton - color="primary" - onClick={submitSyncControls} - data-test-subj="SaveSyncControlsButton" - > - {SAVE_CHANGES_BUTTON} - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </ContentSection> - )} <ContentSection title={SYNC_DIAGNOSTICS_TITLE} description={SYNC_DIAGNOSTICS_DESCRIPTION}> <EuiButton target="_blank" diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.tsx index 80f4244101fd8..bcc2aa72668f9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_item.tsx @@ -10,7 +10,6 @@ import React from 'react'; import { EuiButton, EuiComboBox, - EuiComboBoxOptionOption, EuiDatePicker, EuiFlexGroup, EuiFlexItem, @@ -81,13 +80,10 @@ const syncOptions = [ }, ]; -const dayPickerOptions = DAYS_OF_WEEK_VALUES.reduce((options, day) => { - options.push({ - label: DAYS_OF_WEEK_LABELS[day.toUpperCase() as keyof typeof DAYS_OF_WEEK_LABELS], - value: day, - }); - return options; -}, [] as Array<EuiComboBoxOptionOption<string>>); +const dayPickerOptions = DAYS_OF_WEEK_VALUES.map((day) => ({ + label: DAYS_OF_WEEK_LABELS[day.toUpperCase() as keyof typeof DAYS_OF_WEEK_LABELS], + value: day, +})); export const BlockedWindowItem: React.FC<Props> = ({ blockedWindow }) => { const handleSyncTypeChange = () => '#TODO'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_tab.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_tab.test.tsx index 0d5183b5e95e1..7cada1d39fb6e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_tab.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_tab.test.tsx @@ -7,6 +7,7 @@ import '../../../../../__mocks__/shallow_useeffect.mock'; import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { fullContentSources } from '../../../../__mocks__/content_sources.mock'; import { blockedWindow } from './__mocks__/syncronization.mock'; import React from 'react'; @@ -25,6 +26,7 @@ describe('BlockedWindows', () => { }; const mockValues = { blockedWindows: [blockedWindow], + contentSource: fullContentSources[0], }; beforeEach(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_tab.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_tab.tsx index 474bf4cab2a8e..f0227f76d4aa5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_tab.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/blocked_window_tab.tsx @@ -13,13 +13,15 @@ import { EuiButton, EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; import { ADD_LABEL } from '../../../../constants'; import { BLOCKED_EMPTY_STATE_TITLE, BLOCKED_EMPTY_STATE_DESCRIPTION } from '../../constants'; +import { SourceLogic } from '../../source_logic'; import { BlockedWindowItem } from './blocked_window_item'; import { SynchronizationLogic } from './synchronization_logic'; export const BlockedWindows: React.FC = () => { - const { blockedWindows } = useValues(SynchronizationLogic); - const { addBlockedWindow } = useActions(SynchronizationLogic); + const { contentSource } = useValues(SourceLogic); + const { blockedWindows } = useValues(SynchronizationLogic({ contentSource })); + const { addBlockedWindow } = useActions(SynchronizationLogic({ contentSource })); const hasBlockedWindows = blockedWindows.length > 0; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.test.tsx index 08de4b41758a2..283c9a9cebbbd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.test.tsx @@ -7,6 +7,7 @@ import '../../../../../__mocks__/shallow_useeffect.mock'; import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { fullContentSources } from '../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -23,7 +24,9 @@ describe('Frequency', () => { const mockActions = { handleSelectedTabChanged, }; - const mockValues = {}; + const mockValues = { + contentSource: fullContentSources[0], + }; beforeEach(() => { setMockActions(mockActions); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx index fb19c84ecfdd1..3ca34f4960474 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { useActions } from 'kea'; +import { useActions, useValues } from 'kea'; import { EuiButton, @@ -31,6 +31,7 @@ import { DIFFERENT_SYNC_TYPES_LINK_LABEL, SYNC_BEST_PRACTICES_LINK_LABEL, } from '../../constants'; +import { SourceLogic } from '../../source_logic'; import { SourceLayout } from '../source_layout'; import { BlockedWindows } from './blocked_window_tab'; @@ -42,7 +43,8 @@ interface FrequencyProps { } export const Frequency: React.FC<FrequencyProps> = ({ tabId }) => { - const { handleSelectedTabChanged } = useActions(SynchronizationLogic); + const { contentSource } = useValues(SourceLogic); + const { handleSelectedTabChanged } = useActions(SynchronizationLogic({ contentSource })); const tabs = [ { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.test.tsx index fb346ad96117e..ce295b467a09d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.test.tsx @@ -8,21 +8,24 @@ import React from 'react'; import { shallow } from 'enzyme'; +import moment from 'moment'; import { EuiFieldNumber, EuiSuperSelect } from '@elastic/eui'; import { FrequencyItem } from './frequency_item'; describe('FrequencyItem', () => { + const estimate = { + duration: 'PT3D', + nextStart: '2021-09-27T21:39:24+00:00', + lastRun: '2021-09-25T21:39:24+00:00', + }; + const props = { label: 'Item', description: 'My item', duration: 'PT2D', - estimate: { - duration: 'PT3D', - nextStart: '2021-09-27T21:39:24+00:00', - lastRun: '2021-09-25T21:39:24+00:00', - }, + estimate, }; it('renders', () => { @@ -60,5 +63,25 @@ describe('FrequencyItem', () => { expect(wrapper.find(EuiFieldNumber).prop('value')).toEqual(1); expect(wrapper.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('minutes'); }); + + it('handles "nextStart" that is in past', () => { + const wrapper = shallow(<FrequencyItem {...props} />); + + expect( + (wrapper.find('[data-test-subj="nextStartSummary"]').prop('values') as any)!.nextStartTime + ).toEqual('as soon as the currently running job finishes'); + }); + + it('handles "nextStart" that is in future', () => { + const estimateWithPastNextStart = { + ...estimate, + nextStart: moment().add(2, 'days').format(), + }; + const wrapper = shallow(<FrequencyItem {...props} estimate={estimateWithPastNextStart} />); + + expect( + (wrapper.find('[data-test-subj="nextStartSummary"]').prop('values') as any)!.nextStartTime + ).toEqual('in 2 days'); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx index 4e9eec28dc1eb..38f85ff2accaf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx @@ -27,6 +27,8 @@ import { } from '../../../../../shared/constants'; import { SyncEstimate } from '../../../../types'; +import { NEXT_SYNC_RUNNING_MESSAGE } from '../../constants'; + interface Props { label: string; description: string; @@ -53,6 +55,8 @@ export const FrequencyItem: React.FC<Props> = ({ label, description, duration, e const [interval, unit] = formatDuration(duration); const { lastRun, nextStart, duration: durationEstimate } = estimate; const estimateDisplay = durationEstimate && moment.duration(durationEstimate).humanize(); + const nextStartIsPast = moment().isAfter(nextStart); + const nextStartTime = nextStartIsPast ? NEXT_SYNC_RUNNING_MESSAGE : moment(nextStart).fromNow(); const onChange = () => '#TODO'; @@ -86,6 +90,7 @@ export const FrequencyItem: React.FC<Props> = ({ label, description, duration, e const nextStartSummary = ( <FormattedMessage + data-test-subj="nextStartSummary" id="xpack.enterpriseSearch.workplaceSearch.contentSources.synchronization.nextStartSummary" defaultMessage="{nextStartStrong} will begin {nextStartTime}." values={{ @@ -97,7 +102,7 @@ export const FrequencyItem: React.FC<Props> = ({ label, description, duration, e /> </strong> ), - nextStartTime: moment(nextStart).fromNow(), + nextStartTime, }} /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.test.tsx new file mode 100644 index 0000000000000..42a08084db418 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { fullContentSources } from '../../../../__mocks__/content_sources.mock'; +import { blockedWindow } from './__mocks__/syncronization.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiSwitch } from '@elastic/eui'; + +import { ObjectsAndAssets } from './objects_and_assets'; + +describe('ObjectsAndAssets', () => { + const setThumbnailsChecked = jest.fn(); + const setContentExtractionChecked = jest.fn(); + const updateSyncSettings = jest.fn(); + const resetSyncSettings = jest.fn(); + const contentSource = fullContentSources[0]; + + const mockActions = { + setThumbnailsChecked, + setContentExtractionChecked, + updateSyncSettings, + resetSyncSettings, + }; + const mockValues = { + dataLoading: false, + blockedWindows: [blockedWindow], + contentSource, + thumbnailsChecked: true, + contentExtractionChecked: true, + hasUnsavedObjectsAndAssetsChanges: false, + }; + + beforeEach(() => { + setMockActions(mockActions); + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(<ObjectsAndAssets />); + + expect(wrapper.find(EuiSwitch)).toHaveLength(2); + }); + + it('handles thumbnails switch change', () => { + const wrapper = shallow(<ObjectsAndAssets />); + wrapper + .find('[data-test-subj="ThumbnailsToggle"]') + .simulate('change', { target: { checked: false } }); + + expect(setThumbnailsChecked).toHaveBeenCalledWith(false); + }); + + it('handles content extraction switch change', () => { + const wrapper = shallow(<ObjectsAndAssets />); + wrapper + .find('[data-test-subj="ContentExtractionToggle"]') + .simulate('change', { target: { checked: false } }); + + expect(setContentExtractionChecked).toHaveBeenCalledWith(false); + }); + + it('renders correct text when areThumbnailsConfigEnabled is false', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...contentSource, + areThumbnailsConfigEnabled: false, + }, + }); + const wrapper = shallow(<ObjectsAndAssets />); + + expect(wrapper.find('[data-test-subj="ThumbnailsToggle"]').prop('label')).toEqual( + 'Sync thumbnails - disabled at global configuration level' + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.tsx index 4c2804459f1ba..98abdb8bf67ea 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.tsx @@ -7,33 +7,113 @@ import React from 'react'; -import { EuiHorizontalRule, EuiLink } from '@elastic/eui'; +import { useActions, useValues } from 'kea'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLink, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; + +import { SAVE_BUTTON_LABEL } from '../../../../../shared/constants'; +import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { NAV } from '../../../../constants'; +import { NAV, RESET_BUTTON } from '../../../../constants'; import { OBJECTS_AND_ASSETS_DOCS_URL } from '../../../../routes'; import { + SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL, + SYNC_MANAGEMENT_THUMBNAILS_LABEL, + SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL, SOURCE_OBJECTS_AND_ASSETS_DESCRIPTION, SYNC_OBJECTS_TYPES_LINK_LABEL, + SOURCE_OBJECTS_AND_ASSETS_LABEL, + SYNC_UNSAVED_CHANGES_MESSAGE, } from '../../constants'; +import { SourceLogic } from '../../source_logic'; import { SourceLayout } from '../source_layout'; +import { SynchronizationLogic } from './synchronization_logic'; + export const ObjectsAndAssets: React.FC = () => { + const { contentSource, dataLoading } = useValues(SourceLogic); + const { thumbnailsChecked, contentExtractionChecked, hasUnsavedObjectsAndAssetsChanges } = + useValues(SynchronizationLogic({ contentSource })); + const { + setThumbnailsChecked, + setContentExtractionChecked, + updateSyncSettings, + resetSyncSettings, + } = useActions(SynchronizationLogic({ contentSource })); + + const { areThumbnailsConfigEnabled } = contentSource; + + const actions = ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiButtonEmpty onClick={resetSyncSettings} disabled={!hasUnsavedObjectsAndAssetsChanges}> + {RESET_BUTTON} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem> + <EuiButton fill onClick={updateSyncSettings} disabled={!hasUnsavedObjectsAndAssetsChanges}> + {SAVE_BUTTON_LABEL} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + ); + return ( <SourceLayout pageChrome={[NAV.SYNCHRONIZATION_OBJECTS_AND_ASSETS]} pageViewTelemetry="source_synchronization" - isLoading={false} + isLoading={dataLoading} > + <UnsavedChangesPrompt + hasUnsavedChanges={hasUnsavedObjectsAndAssetsChanges} + messageText={SYNC_UNSAVED_CHANGES_MESSAGE} + /> <ViewContentHeader title={NAV.SYNCHRONIZATION_OBJECTS_AND_ASSETS} description={SOURCE_OBJECTS_AND_ASSETS_DESCRIPTION} + action={actions} /> <EuiLink href={OBJECTS_AND_ASSETS_DOCS_URL} external> {SYNC_OBJECTS_TYPES_LINK_LABEL} </EuiLink> <EuiHorizontalRule /> - <div>TODO</div> + <EuiText size="m">{SOURCE_OBJECTS_AND_ASSETS_LABEL}</EuiText> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiSwitch + checked={thumbnailsChecked} + onChange={(e) => setThumbnailsChecked(e.target.checked)} + label={ + areThumbnailsConfigEnabled + ? SYNC_MANAGEMENT_THUMBNAILS_LABEL + : SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL + } + disabled={!areThumbnailsConfigEnabled} + data-test-subj="ThumbnailsToggle" + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiSwitch + checked={contentExtractionChecked} + onChange={(e) => setContentExtractionChecked(e.target.checked)} + label={SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL} + data-test-subj="ContentExtractionToggle" + /> + </EuiFlexItem> + </EuiFlexGroup> </SourceLayout> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization.test.tsx index 632af08611ca9..fb9cdc6916fa9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization.test.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import { setMockValues } from '../../../../../__mocks__/kea_logic'; +import { setMockValues, setMockActions } from '../../../../../__mocks__/kea_logic'; +import { fullContentSources } from '../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -16,8 +17,15 @@ import { EuiLink, EuiCallOut, EuiSwitch } from '@elastic/eui'; import { Synchronization } from './synchronization'; describe('Synchronization', () => { + const updateSyncEnabled = jest.fn(); + const mockvalues = { contentSource: fullContentSources[0] }; + + beforeEach(() => { + setMockActions({ updateSyncEnabled }); + setMockValues(mockvalues); + }); + it('renders when config enabled', () => { - setMockValues({ contentSource: { isSyncConfigEnabled: true } }); const wrapper = shallow(<Synchronization />); expect(wrapper.find(EuiLink)).toHaveLength(1); @@ -25,9 +33,16 @@ describe('Synchronization', () => { }); it('renders when config disabled', () => { - setMockValues({ contentSource: { isSyncConfigEnabled: false } }); + setMockValues({ contentSource: { isSyncConfigEnabled: false, indexing: { enabled: true } } }); const wrapper = shallow(<Synchronization />); expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); + + it('handles EuiSwitch change event', () => { + const wrapper = shallow(<Synchronization />); + wrapper.find(EuiSwitch).simulate('change', { target: { checked: true } }); + + expect(updateSyncEnabled).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization.tsx index 21daee8f26d40..21c44225615ea 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; import { EuiCallOut, EuiLink, EuiPanel, EuiSwitch, EuiSpacer, EuiText } from '@elastic/eui'; @@ -25,17 +25,23 @@ import { import { SourceLogic } from '../../source_logic'; import { SourceLayout } from '../source_layout'; +import { SynchronizationLogic } from './synchronization_logic'; + export const Synchronization: React.FC = () => { + const { contentSource } = useValues(SourceLogic); + const { updateSyncEnabled } = useActions(SynchronizationLogic({ contentSource })); + const { - contentSource: { isSyncConfigEnabled }, - } = useValues(SourceLogic); + isSyncConfigEnabled, + indexing: { enabled }, + } = contentSource; - const onChange = (checked: boolean) => `#TODO: ${checked}`; + const onChange = (checked: boolean) => updateSyncEnabled(checked); const syncToggle = ( <EuiPanel hasBorder> <EuiSwitch label={SOURCE_SYNCRONIZATION_TOGGLE_LABEL} - checked + checked={enabled} onChange={(e) => onChange(e.target.checked)} /> <EuiSpacer size="m" /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.test.ts index 50553d1493417..c51ef6cf2bf34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.test.ts @@ -5,14 +5,22 @@ * 2.0. */ -import { LogicMounter, mockKibanaValues } from '../../../../../__mocks__/kea_logic'; +import { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, + mockKibanaValues, +} from '../../../../../__mocks__/kea_logic'; +import { fullContentSources } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test/jest'; -const contentSource = { id: 'source123' }; +import { expectedAsyncError } from '../../../../../test_helpers'; + jest.mock('../../source_logic', () => ({ - SourceLogic: { values: { contentSource } }, + SourceLogic: { actions: { setContentSource: jest.fn() } }, })); +import { SourceLogic } from '../../source_logic'; jest.mock('../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, @@ -21,17 +29,23 @@ jest.mock('../../../../app_logic', () => ({ import { SynchronizationLogic, emptyBlockedWindow } from './synchronization_logic'; describe('SynchronizationLogic', () => { + const { http } = mockHttpValues; + const { flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers; const { navigateToUrl } = mockKibanaValues; const { mount } = new LogicMounter(SynchronizationLogic); + const contentSource = fullContentSources[0]; const defaultValues = { navigatingBetweenTabs: false, + hasUnsavedObjectsAndAssetsChanges: false, + contentExtractionChecked: true, + thumbnailsChecked: true, blockedWindows: [], }; beforeEach(() => { jest.clearAllMocks(); - mount(); + mount({}, { contentSource }); }); it('has expected default values', () => { @@ -50,6 +64,18 @@ describe('SynchronizationLogic', () => { expect(SynchronizationLogic.values.blockedWindows).toEqual([emptyBlockedWindow]); }); + + it('setThumbnailsChecked', () => { + SynchronizationLogic.actions.setThumbnailsChecked(false); + + expect(SynchronizationLogic.values.thumbnailsChecked).toEqual(false); + }); + + it('setContentExtractionChecked', () => { + SynchronizationLogic.actions.setContentExtractionChecked(false); + + expect(SynchronizationLogic.values.contentExtractionChecked).toEqual(false); + }); }); describe('listeners', () => { @@ -63,7 +89,7 @@ describe('SynchronizationLogic', () => { await nextTick(); expect(setNavigatingBetweenTabsSpy).toHaveBeenCalledWith(true); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/source123/synchronization/frequency'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/123/synchronization/frequency'); }); it('calls calls correct route for "blocked_time_windows"', async () => { @@ -71,8 +97,125 @@ describe('SynchronizationLogic', () => { await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith( - '/sources/source123/synchronization/frequency/blocked_windows' + '/sources/123/synchronization/frequency/blocked_windows' + ); + }); + }); + + describe('updateSyncEnabled', () => { + it('calls API and sets values for false value', async () => { + const setContentSourceSpy = jest.spyOn(SourceLogic.actions, 'setContentSource'); + const promise = Promise.resolve(contentSource); + http.patch.mockReturnValue(promise); + SynchronizationLogic.actions.updateSyncEnabled(false); + + expect(http.patch).toHaveBeenCalledWith( + '/internal/workplace_search/org/sources/123/settings', + { + body: JSON.stringify({ + content_source: { + indexing: { enabled: false }, + }, + }), + } + ); + await promise; + expect(setContentSourceSpy).toHaveBeenCalledWith(contentSource); + expect(flashSuccessToast).toHaveBeenCalledWith('Source synchronization disabled.'); + }); + + it('calls API and sets values for true value', async () => { + const promise = Promise.resolve(contentSource); + http.patch.mockReturnValue(promise); + SynchronizationLogic.actions.updateSyncEnabled(true); + + expect(http.patch).toHaveBeenCalledWith( + '/internal/workplace_search/org/sources/123/settings', + { + body: JSON.stringify({ + content_source: { + indexing: { enabled: true }, + }, + }), + } + ); + await promise; + expect(flashSuccessToast).toHaveBeenCalledWith('Source synchronization enabled.'); + }); + + it('handles error', async () => { + const error = { + response: { + error: 'this is an error', + status: 400, + }, + }; + const promise = Promise.reject(error); + http.patch.mockReturnValue(promise); + SynchronizationLogic.actions.updateSyncEnabled(false); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith(error); + }); + }); + + describe('resetSyncSettings', () => { + it('calls methods', async () => { + const setThumbnailsCheckedSpy = jest.spyOn( + SynchronizationLogic.actions, + 'setThumbnailsChecked' + ); + const setContentExtractionCheckedSpy = jest.spyOn( + SynchronizationLogic.actions, + 'setContentExtractionChecked' ); + SynchronizationLogic.actions.resetSyncSettings(); + + expect(setThumbnailsCheckedSpy).toHaveBeenCalledWith(true); + expect(setContentExtractionCheckedSpy).toHaveBeenCalledWith(true); + }); + }); + + describe('updateSyncSettings', () => { + it('calls API and sets values', async () => { + const setContentSourceSpy = jest.spyOn(SourceLogic.actions, 'setContentSource'); + const promise = Promise.resolve(contentSource); + http.patch.mockReturnValue(promise); + SynchronizationLogic.actions.updateSyncSettings(); + + expect(http.patch).toHaveBeenCalledWith( + '/internal/workplace_search/org/sources/123/settings', + { + body: JSON.stringify({ + content_source: { + indexing: { + features: { + content_extraction: { enabled: true }, + thumbnails: { enabled: true }, + }, + }, + }, + }), + } + ); + await promise; + expect(setContentSourceSpy).toHaveBeenCalledWith(contentSource); + expect(flashSuccessToast).toHaveBeenCalledWith('Source synchronization settings updated.'); + }); + + it('handles error', async () => { + const error = { + response: { + error: 'this is an error', + status: 400, + }, + }; + const promise = Promise.reject(error); + http.patch.mockReturnValue(promise); + SynchronizationLogic.actions.updateSyncSettings(); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith(error); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts index 4f67f6471e6e1..4106ab70cf201 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts @@ -10,6 +10,8 @@ import moment from 'moment'; export type TabId = 'source_sync_frequency' | 'blocked_time_windows'; +import { flashAPIErrors, flashSuccessToast } from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; import { @@ -19,17 +21,30 @@ import { } from '../../../../routes'; import { BlockedWindow } from '../../../../types'; +import { + SYNC_ENABLED_MESSAGE, + SYNC_DISABLED_MESSAGE, + SYNC_SETTINGS_UPDATED_MESSAGE, +} from '../../constants'; import { SourceLogic } from '../../source_logic'; interface SynchronizationActions { setNavigatingBetweenTabs(navigatingBetweenTabs: boolean): boolean; handleSelectedTabChanged(tabId: TabId): TabId; addBlockedWindow(): void; + updateSyncSettings(): void; + resetSyncSettings(): void; + updateSyncEnabled(enabled: boolean): boolean; + setThumbnailsChecked(checked: boolean): boolean; + setContentExtractionChecked(checked: boolean): boolean; } interface SynchronizationValues { - hasUnsavedChanges: boolean; navigatingBetweenTabs: boolean; + hasUnsavedFrequencyChanges: boolean; + hasUnsavedObjectsAndAssetsChanges: boolean; + thumbnailsChecked: boolean; + contentExtractionChecked: boolean; blockedWindows: BlockedWindow[]; } @@ -43,12 +58,18 @@ export const emptyBlockedWindow: BlockedWindow = { export const SynchronizationLogic = kea< MakeLogicType<SynchronizationValues, SynchronizationActions> >({ + path: ['enterprise_search', 'workplace_search', 'synchronization_logic'], actions: { setNavigatingBetweenTabs: (navigatingBetweenTabs: boolean) => navigatingBetweenTabs, handleSelectedTabChanged: (tabId: TabId) => tabId, + updateSyncEnabled: (enabled: boolean) => enabled, + setThumbnailsChecked: (checked: boolean) => checked, + setContentExtractionChecked: (checked: boolean) => checked, + updateSyncSettings: true, + resetSyncSettings: true, addBlockedWindow: true, }, - reducers: { + reducers: ({ props }) => ({ navigatingBetweenTabs: [ false, { @@ -61,11 +82,47 @@ export const SynchronizationLogic = kea< addBlockedWindow: (state, _) => [...state, emptyBlockedWindow], }, ], - }, - listeners: ({ actions }) => ({ + thumbnailsChecked: [ + props.contentSource.indexing.features.thumbnails.enabled, + { + setThumbnailsChecked: (_, thumbnailsChecked) => thumbnailsChecked, + }, + ], + contentExtractionChecked: [ + props.contentSource.indexing.features.contentExtraction.enabled, + { + setContentExtractionChecked: (_, contentExtractionChecked) => contentExtractionChecked, + }, + ], + }), + selectors: ({ selectors }) => ({ + hasUnsavedObjectsAndAssetsChanges: [ + () => [ + selectors.thumbnailsChecked, + selectors.contentExtractionChecked, + (_, props) => props.contentSource, + ], + (thumbnailsChecked, contentExtractionChecked, contentSource) => { + const { + indexing: { + features: { + thumbnails: { enabled: thumbnailsEnabled }, + contentExtraction: { enabled: contentExtractionEnabled }, + }, + }, + } = contentSource; + + return ( + thumbnailsChecked !== thumbnailsEnabled || + contentExtractionChecked !== contentExtractionEnabled + ); + }, + ], + }), + listeners: ({ actions, values, props }) => ({ handleSelectedTabChanged: async (tabId, breakpoint) => { const { isOrganization } = AppLogic.values; - const { id: sourceId } = SourceLogic.values.contentSource; + const { id: sourceId } = props.contentSource; const path = tabId === 'source_sync_frequency' ? getContentSourcePath(SYNC_FREQUENCY_PATH, sourceId, isOrganization) @@ -82,5 +139,51 @@ export const SynchronizationLogic = kea< KibanaLogic.values.navigateToUrl(path); actions.setNavigatingBetweenTabs(false); }, + updateSyncEnabled: async (enabled) => { + const { id: sourceId } = props.contentSource; + const route = `/internal/workplace_search/org/sources/${sourceId}/settings`; + const successMessage = enabled ? SYNC_ENABLED_MESSAGE : SYNC_DISABLED_MESSAGE; + + try { + const response = await HttpLogic.values.http.patch(route, { + body: JSON.stringify({ content_source: { indexing: { enabled } } }), + }); + + SourceLogic.actions.setContentSource(response); + flashSuccessToast(successMessage); + } catch (e) { + flashAPIErrors(e); + } + }, + resetSyncSettings: () => { + actions.setThumbnailsChecked(props.contentSource.indexing.features.thumbnails.enabled); + actions.setContentExtractionChecked( + props.contentSource.indexing.features.contentExtraction.enabled + ); + }, + updateSyncSettings: async () => { + const { id: sourceId } = props.contentSource; + const route = `/internal/workplace_search/org/sources/${sourceId}/settings`; + + try { + const response = await HttpLogic.values.http.patch(route, { + body: JSON.stringify({ + content_source: { + indexing: { + features: { + content_extraction: { enabled: values.contentExtractionChecked }, + thumbnails: { enabled: values.thumbnailsChecked }, + }, + }, + }, + }), + }); + + SourceLogic.actions.setContentSource(response); + flashSuccessToast(SYNC_SETTINGS_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index ae55a970a4f9f..4e46100b591b9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -306,20 +306,6 @@ export const SOURCE_CONFIG_TITLE = i18n.translate( } ); -export const SYNC_MANAGEMENT_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementTitle', - { - defaultMessage: 'Sync management', - } -); - -export const SYNC_MANAGEMENT_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementDescription', - { - defaultMessage: 'Enable and disable extraction of specific content for this source.', - } -); - export const SYNC_MANAGEMENT_SYNCHRONIZE_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementSynchronizeLabel', { @@ -344,7 +330,7 @@ export const SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL = i18n.translate( export const SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementContentExtractionLabel', { - defaultMessage: 'Sync all text and content', + defaultMessage: 'Sync full-text from files', } ); @@ -565,6 +551,13 @@ export const SOURCE_OBJECTS_AND_ASSETS_DESCRIPTION = i18n.translate( } ); +export const SOURCE_OBJECTS_AND_ASSETS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceObjectsAndAssetsLabel', + { + defaultMessage: 'Object and details to include in search results', + } +); + export const SOURCE_SYNCRONIZATION_TOGGLE_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncronizationToggleLabel', { @@ -711,3 +704,38 @@ export const BLOCKED_EMPTY_STATE_DESCRIPTION = i18n.translate( defaultMessage: 'Add a blocked time window to only perform syncs at the right time.', } ); + +export const SYNC_ENABLED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.syncEnabledMessage', + { + defaultMessage: 'Source synchronization enabled.', + } +); + +export const SYNC_DISABLED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.syncDisabledMessage', + { + defaultMessage: 'Source synchronization disabled.', + } +); + +export const SYNC_SETTINGS_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.syncSettingsUpdatedMessage', + { + defaultMessage: 'Source synchronization settings updated.', + } +); + +export const SYNC_UNSAVED_CHANGES_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.syncUnsavedChangesMessage', + { + defaultMessage: 'Your changes have not been saved. Are you sure you want to leave?', + } +); + +export const NEXT_SYNC_RUNNING_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.nextSyncRunningMessage', + { + defaultMessage: 'as soon as the currently running job finishes', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts index 0d8d5684c4a4c..1fb4477cea5c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts @@ -58,8 +58,8 @@ describe('SourceLogic', () => { }); describe('actions', () => { - it('onInitializeSource', () => { - SourceLogic.actions.onInitializeSource(contentSource); + it('setContentSource', () => { + SourceLogic.actions.setContentSource(contentSource); expect(SourceLogic.values.contentSource).toEqual(contentSource); expect(SourceLogic.values.dataLoading).toEqual(false); @@ -67,7 +67,7 @@ describe('SourceLogic', () => { it('onUpdateSourceName', () => { const NAME = 'foo'; - SourceLogic.actions.onInitializeSource(contentSource); + SourceLogic.actions.setContentSource(contentSource); SourceLogic.actions.onUpdateSourceName(NAME); expect(SourceLogic.values.contentSource).toEqual({ @@ -88,7 +88,7 @@ describe('SourceLogic', () => { it('setContentFilterValue', () => { const VALUE = 'bar'; SourceLogic.actions.setSearchResults(searchServerResponse); - SourceLogic.actions.onInitializeSource(contentSource); + SourceLogic.actions.setContentSource(contentSource); SourceLogic.actions.setContentFilterValue(VALUE); expect(SourceLogic.values.contentMeta).toEqual({ @@ -127,7 +127,7 @@ describe('SourceLogic', () => { describe('listeners', () => { describe('initializeSource', () => { it('calls API and sets values (org)', async () => { - const onInitializeSourceSpy = jest.spyOn(SourceLogic.actions, 'onInitializeSource'); + const onInitializeSourceSpy = jest.spyOn(SourceLogic.actions, 'setContentSource'); const promise = Promise.resolve(contentSource); http.get.mockReturnValue(promise); SourceLogic.actions.initializeSource(contentSource.id); @@ -140,7 +140,7 @@ describe('SourceLogic', () => { it('calls API and sets values (account)', async () => { AppLogic.values.isOrganization = false; - const onInitializeSourceSpy = jest.spyOn(SourceLogic.actions, 'onInitializeSource'); + const onInitializeSourceSpy = jest.spyOn(SourceLogic.actions, 'setContentSource'); const promise = Promise.resolve(contentSource); http.get.mockReturnValue(promise); SourceLogic.actions.initializeSource(contentSource.id); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index c31eacda69515..d10400bc5ba2d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -23,7 +23,7 @@ import { PRIVATE_SOURCES_PATH, SOURCES_PATH, getSourcesPath } from '../../routes import { ContentSourceFullData, Meta, DocumentSummaryItem, SourceContentItem } from '../../types'; export interface SourceActions { - onInitializeSource(contentSource: ContentSourceFullData): ContentSourceFullData; + setContentSource(contentSource: ContentSourceFullData): ContentSourceFullData; onUpdateSourceName(name: string): string; setSearchResults(searchResultsResponse: SearchResultsResponse): SearchResultsResponse; initializeFederatedSummary(sourceId: string): { sourceId: string }; @@ -73,7 +73,7 @@ interface SourceUpdatePayload { export const SourceLogic = kea<MakeLogicType<SourceValues, SourceActions>>({ path: ['enterprise_search', 'workplace_search', 'source_logic'], actions: { - onInitializeSource: (contentSource: ContentSourceFullData) => contentSource, + setContentSource: (contentSource: ContentSourceFullData) => contentSource, onUpdateSourceName: (name: string) => name, onUpdateSummary: (summary: object[]) => summary, setSearchResults: (searchResultsResponse: SearchResultsResponse) => searchResultsResponse, @@ -93,7 +93,7 @@ export const SourceLogic = kea<MakeLogicType<SourceValues, SourceActions>>({ contentSource: [ {} as ContentSourceFullData, { - onInitializeSource: (_, contentSource) => contentSource, + setContentSource: (_, contentSource) => contentSource, onUpdateSourceName: (contentSource, name) => ({ ...contentSource, name, @@ -108,7 +108,7 @@ export const SourceLogic = kea<MakeLogicType<SourceValues, SourceActions>>({ dataLoading: [ true, { - onInitializeSource: () => false, + setContentSource: () => false, resetSourceState: () => true, }, ], @@ -158,7 +158,7 @@ export const SourceLogic = kea<MakeLogicType<SourceValues, SourceActions>>({ try { const response = await HttpLogic.values.http.get(route); - actions.onInitializeSource(response); + actions.setContentSource(response); if (response.isFederatedSource) { actions.initializeFederatedSummary(sourceId); } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts index e6bfaa4a9cca2..2bdcfb9fe9d58 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts @@ -38,6 +38,35 @@ describe('search relevance insights routes', () => { }); }); + describe('PUT /internal/app_search/engines/{name}/search_relevance_suggestions', () => { + const mockRouter = new MockRouter({ + method: 'put', + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions', + }); + + beforeEach(() => { + registerSearchRelevanceSuggestionsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: { + query: 'some query', + type: 'curation', + status: 'applied', + }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions', + }); + }); + }); + describe('GET /internal/app_search/engines/{name}/search_relevance_suggestions/settings', () => { const mockRouter = new MockRouter({ method: 'get', diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts index c6fa108a5629e..8b3b204c24d70 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts @@ -39,6 +39,20 @@ export function registerSearchRelevanceSuggestionsRoutes({ }) ); + router.put( + skipBodyValidation({ + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }), + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions', + }) + ); + router.get( { path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions/settings', diff --git a/x-pack/plugins/event_log/server/event_log_service.mock.ts b/x-pack/plugins/event_log/server/event_log_service.mock.ts index a6e43a5b488c7..a3ad81eb0e5a6 100644 --- a/x-pack/plugins/event_log/server/event_log_service.mock.ts +++ b/x-pack/plugins/event_log/server/event_log_service.mock.ts @@ -10,7 +10,6 @@ import { eventLoggerMock } from './event_logger.mock'; const createEventLogServiceMock = () => { const mock: jest.Mocked<IEventLogService> = { - isEnabled: jest.fn(), isLoggingEntries: jest.fn(), isIndexingEntries: jest.fn(), registerProviderActions: jest.fn(), diff --git a/x-pack/plugins/event_log/server/event_log_service.test.ts b/x-pack/plugins/event_log/server/event_log_service.test.ts index ad8b8c06b47f3..aad51a03b2f06 100644 --- a/x-pack/plugins/event_log/server/event_log_service.test.ts +++ b/x-pack/plugins/event_log/server/event_log_service.test.ts @@ -19,14 +19,13 @@ describe('EventLogService', () => { const esContext = contextMock.create(); function getService(config: IEventLogConfig) { - const { enabled, logEntries, indexEntries } = config; + const { logEntries, indexEntries } = config; return new EventLogService({ esContext, systemLogger, kibanaUUID: '42', savedObjectProviderRegistry, config: { - enabled, logEntries, indexEntries, }, @@ -37,30 +36,19 @@ describe('EventLogService', () => { test('returns config values from service methods', () => { let service; - service = getService({ enabled: true, logEntries: true, indexEntries: true }); - expect(service.isEnabled()).toEqual(true); + service = getService({ logEntries: true, indexEntries: true }); expect(service.isLoggingEntries()).toEqual(true); expect(service.isIndexingEntries()).toEqual(true); - service = getService({ enabled: true, logEntries: false, indexEntries: true }); - expect(service.isEnabled()).toEqual(true); + service = getService({ logEntries: false, indexEntries: true }); expect(service.isLoggingEntries()).toEqual(false); expect(service.isIndexingEntries()).toEqual(true); - service = getService({ enabled: true, logEntries: true, indexEntries: false }); - expect(service.isEnabled()).toEqual(true); + service = getService({ logEntries: true, indexEntries: false }); expect(service.isLoggingEntries()).toEqual(true); expect(service.isIndexingEntries()).toEqual(false); - service = getService({ enabled: true, logEntries: false, indexEntries: false }); - expect(service.isEnabled()).toEqual(true); - expect(service.isLoggingEntries()).toEqual(false); - expect(service.isIndexingEntries()).toEqual(false); - - // this is the only non-obvious one; when enabled is false, - // logging/indexing will be false as well. - service = getService({ enabled: false, logEntries: true, indexEntries: true }); - expect(service.isEnabled()).toEqual(false); + service = getService({ logEntries: false, indexEntries: false }); expect(service.isLoggingEntries()).toEqual(false); expect(service.isIndexingEntries()).toEqual(false); }); diff --git a/x-pack/plugins/event_log/server/event_log_service.ts b/x-pack/plugins/event_log/server/event_log_service.ts index f6e1533aa1155..993631ed3ca8a 100644 --- a/x-pack/plugins/event_log/server/event_log_service.ts +++ b/x-pack/plugins/event_log/server/event_log_service.ts @@ -55,16 +55,12 @@ export class EventLogService implements IEventLogService { this.kibanaVersion = kibanaVersion; } - public isEnabled(): boolean { - return this.config.enabled; - } - public isLoggingEntries(): boolean { - return this.isEnabled() && this.config.logEntries; + return this.config.logEntries; } public isIndexingEntries(): boolean { - return this.isEnabled() && this.config.indexEntries; + return this.config.indexEntries; } registerProviderActions(provider: string, actions: string[]): void { diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index d90fd93c60043..43d791a18b5fc 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -31,7 +31,7 @@ describe('EventLogger', () => { service = new EventLogService({ esContext, systemLogger, - config: { enabled: true, logEntries: true, indexEntries: true }, + config: { logEntries: true, indexEntries: true }, kibanaUUID: KIBANA_SERVER_UUID, savedObjectProviderRegistry: savedObjectProviderRegistryMock.create(), kibanaVersion: '1.0.1', diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts index bcda73da215ae..97335f9cda8d1 100644 --- a/x-pack/plugins/event_log/server/event_logger.ts +++ b/x-pack/plugins/event_log/server/event_logger.ts @@ -66,8 +66,6 @@ export class EventLogger implements IEventLogger { // non-blocking, but spawns an async task to do the work logEvent(eventProperties: IEvent): void { - if (!this.eventLogService.isEnabled()) return; - const event: IEvent = {}; const fixedProperties = { ecs: { diff --git a/x-pack/plugins/event_log/server/index.ts b/x-pack/plugins/event_log/server/index.ts index deeee970ce68a..14c121664d4a8 100644 --- a/x-pack/plugins/event_log/server/index.ts +++ b/x-pack/plugins/event_log/server/index.ts @@ -26,20 +26,5 @@ export { createReadySignal } from './lib/ready_signal'; export const config: PluginConfigDescriptor<IEventLogConfig> = { schema: ConfigSchema, - deprecations: () => [ - (settings, fromPath, addDeprecation) => { - if ( - settings?.xpack?.eventLog?.enabled === false || - settings?.xpack?.eventLog?.enabled === true - ) { - addDeprecation({ - message: `"xpack.eventLog.enabled" is deprecated. The ability to disable this plugin will be removed in 8.0.0.`, - correctiveActions: { - manualSteps: [`Remove "xpack.eventLog.enabled" from your kibana configs.`], - }, - }); - } - }, - ], }; export const plugin = (context: PluginInitializerContext) => new Plugin(context); diff --git a/x-pack/plugins/event_log/server/plugin.test.ts b/x-pack/plugins/event_log/server/plugin.test.ts index 166b084deb6bf..fa66d4b0b02b3 100644 --- a/x-pack/plugins/event_log/server/plugin.test.ts +++ b/x-pack/plugins/event_log/server/plugin.test.ts @@ -21,7 +21,6 @@ describe('event_log plugin', () => { const setup = plugin.setup(coreSetup); expect(typeof setup.getLogger).toBe('function'); expect(typeof setup.getProviderActions).toBe('function'); - expect(typeof setup.isEnabled).toBe('function'); expect(typeof setup.isIndexingEntries).toBe('function'); expect(typeof setup.isLoggingEntries).toBe('function'); expect(typeof setup.isProviderActionRegistered).toBe('function'); diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index 0750e89473b8e..c50bed7e01dd5 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -18,7 +18,6 @@ import { SavedObjectProvider } from './saved_object_provider_registry'; export const SAVED_OBJECT_REL_PRIMARY = 'primary'; export const ConfigSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), logEntries: schema.boolean({ defaultValue: false }), indexEntries: schema.boolean({ defaultValue: true }), }); @@ -27,7 +26,6 @@ export type IEventLogConfig = TypeOf<typeof ConfigSchema>; // the object exposed by plugin.setup() export interface IEventLogService { - isEnabled(): boolean; isLoggingEntries(): boolean; isIndexingEntries(): boolean; registerProviderActions(provider: string, actions: string[]): void; diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index 5294c31d6a289..79ea19360c849 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -59,6 +59,10 @@ export const epmRouteService = { getRemovePath: (pkgkey: string) => { return EPM_API_ROUTES.DELETE_PATTERN.replace('{pkgkey}', pkgkey).replace(/\/$/, ''); // trim trailing slash }, + + getUpdatePath: (pkgkey: string) => { + return EPM_API_ROUTES.INFO_PATTERN.replace('{pkgkey}', pkgkey); + }, }; export const packagePolicyRouteService = { diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 06e3d13c2394b..20f41174a9847 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -351,6 +351,7 @@ export interface EpmPackageAdditions { assets: AssetsGroupedByServiceByType; removable?: boolean; notice?: string; + keepPoliciesUpToDate?: boolean; } type Merge<FirstType, SecondType> = Omit<FirstType, Extract<keyof FirstType, keyof SecondType>> & @@ -364,7 +365,7 @@ export type PackageListItem = Installable<RegistrySearchResult> & { }; export interface IntegrationCardItem { - uiInternalPathUrl: string; + url: string; release?: 'beta' | 'experimental' | 'ga'; description: string; name: string; @@ -391,6 +392,7 @@ export interface Installation extends SavedObjectAttributes { install_version: string; install_started_at: string; install_source: InstallSource; + keep_policies_up_to_date: boolean; } export interface PackageUsageStats { 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 51772eadca69e..cfe0b4abdcd3c 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts @@ -57,6 +57,19 @@ export interface GetInfoResponse { response: PackageInfo; } +export interface UpdatePackageRequest { + params: { + pkgkey: string; + }; + body: { + keepPoliciesUpToDate?: boolean; + }; +} + +export interface UpdatePackageResponse { + response: PackageInfo; +} + export interface GetStatsRequest { params: { pkgname: string; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index 3149a454c6c52..0202fc3351fc0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -64,24 +64,6 @@ const breadcrumbGetters: { }, { text: policyName }, ], - add_integration_from_policy: ({ policyName, policyId }) => [ - BASE_BREADCRUMB, - { - href: pagePathGetters.policies()[1], - text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Agent policies', - }), - }, - { - href: pagePathGetters.policy_details({ policyId })[1], - text: policyName, - }, - { - text: i18n.translate('xpack.fleet.breadcrumbs.addPackagePolicyPageTitle', { - defaultMessage: 'Add integration', - }), - }, - ], add_integration_to_policy: ({ pkgTitle, pkgkey, integration }) => [ INTEGRATIONS_BASE_BREADCRUMB, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx index 9354ae4dbe4f9..59498325bf91f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx @@ -48,7 +48,7 @@ export const CreatePackagePolicyPageLayout: React.FunctionComponent<{ 'data-test-subj': dataTestSubj, tabs = [], }) => { - const isAdd = useMemo(() => ['policy', 'package'].includes(from), [from]); + const isAdd = useMemo(() => ['package'].includes(from), [from]); const isEdit = useMemo(() => ['edit', 'package-edit'].includes(from), [from]); const isUpgrade = useMemo( () => diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx index 398421278b723..954addd4202b1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx @@ -17,12 +17,17 @@ import { EuiFieldPassword, EuiCodeBlock, } from '@elastic/eui'; +import styled from 'styled-components'; import type { RegistryVarsEntry } from '../../../../types'; import { CodeEditor } from '../../../../../../../../../../src/plugins/kibana_react/public'; import { MultiTextInput } from './multi_text_input'; +const FixedHeightDiv = styled.div` + height: 300px; +`; + export const PackagePolicyInputVarField: React.FunctionComponent<{ varDef: RegistryVarsEntry; value: any; @@ -55,31 +60,34 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ <pre>{value}</pre> </EuiCodeBlock> ) : ( - <CodeEditor - languageId="yaml" - width="100%" - height="300px" - value={value} - onChange={onChange} - options={{ - minimap: { - enabled: false, - }, - ariaLabel: i18n.translate('xpack.fleet.packagePolicyField.yamlCodeEditor', { - defaultMessage: 'YAML Code Editor', - }), - scrollBeyondLastLine: false, - wordWrap: 'off', - wrappingIndent: 'indent', - tabSize: 2, - // To avoid left margin - lineNumbers: 'off', - lineNumbersMinChars: 0, - glyphMargin: false, - folding: false, - lineDecorationsWidth: 0, - }} - /> + <FixedHeightDiv> + <CodeEditor + languageId="yaml" + width="100%" + height="300px" + value={value} + onChange={onChange} + options={{ + minimap: { + enabled: false, + }, + ariaLabel: i18n.translate('xpack.fleet.packagePolicyField.yamlCodeEditor', { + defaultMessage: 'YAML Code Editor', + }), + scrollBeyondLastLine: false, + wordWrap: 'off', + wrappingIndent: 'indent', + tabSize: 2, + // To avoid left margin + lineNumbers: 'off', + lineNumbersMinChars: 0, + glyphMargin: false, + folding: false, + lineDecorationsWidth: 0, + overviewRulerBorder: false, + }} + /> + </FixedHeightDiv> ); case 'bool': return ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx index a83f6902ed056..ffc9cba90efea 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx @@ -27,12 +27,7 @@ import type { ApplicationStart } from 'kibana/public'; import { safeLoad } from 'js-yaml'; import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; -import type { - AgentPolicy, - PackageInfo, - NewPackagePolicy, - CreatePackagePolicyRouteState, -} from '../../../types'; +import type { AgentPolicy, NewPackagePolicy, CreatePackagePolicyRouteState } from '../../../types'; import { useLink, useBreadcrumbs, @@ -40,8 +35,9 @@ import { useStartServices, useConfig, sendGetAgentStatus, + useGetPackageInfoByKey, } from '../../../hooks'; -import { Loading } from '../../../components'; +import { Loading, Error } from '../../../components'; import { ConfirmDeployAgentPolicyModal } from '../components'; import { useIntraAppState, useUIExtension } from '../../../hooks'; import { ExtensionWrapper } from '../../../components'; @@ -74,16 +70,12 @@ interface AddToPolicyParams { policyId?: string; } -interface AddFromPolicyParams { - policyId: string; -} - export const CreatePackagePolicyPage: React.FunctionComponent = () => { const { notifications } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); - const { params } = useRouteMatch<AddToPolicyParams | AddFromPolicyParams>(); + const { params } = useRouteMatch<AddToPolicyParams>(); const { getHref, getPath } = useLink(); const history = useHistory(); const handleNavigateTo = useNavigateToCallback(); @@ -110,10 +102,8 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const from: EditPackagePolicyFrom = 'policyId' in params || queryParamsPolicyId ? 'policy' : 'package'; - // Agent policy and package info states + // Agent policy state const [agentPolicy, setAgentPolicy] = useState<AgentPolicy | undefined>(); - const [packageInfo, setPackageInfo] = useState<PackageInfo>(); - const [isLoadingAgentPolicyStep, setIsLoadingAgentPolicyStep] = useState<boolean>(false); // Retrieve agent count const agentPolicyId = agentPolicy?.id; @@ -142,30 +132,24 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { inputs: [], }); - // Package policy validation state + // Validation state const [validationResults, setValidationResults] = useState<PackagePolicyValidationResults>(); + const [hasAgentPolicyError, setHasAgentPolicyError] = useState<boolean>(false); // Form state - const [formState, setFormState] = useState<PackagePolicyFormState>('INVALID'); - - // Update package info method - const updatePackageInfo = useCallback( - (updatedPackageInfo: PackageInfo | undefined) => { - if (updatedPackageInfo) { - setPackageInfo(updatedPackageInfo); - if (agentPolicy) { - setFormState('VALID'); - } - } else { - setFormState('INVALID'); - setPackageInfo(undefined); - } + const [formState, setFormState] = useState<PackagePolicyFormState>('VALID'); - // eslint-disable-next-line no-console - console.debug('Package info updated', updatedPackageInfo); - }, - [agentPolicy, setPackageInfo, setFormState] - ); + // Fetch package info + const { + data: packageInfoData, + error: packageInfoError, + isLoading: isPackageInfoLoading, + } = useGetPackageInfoByKey(params.pkgkey); + const packageInfo = useMemo(() => { + if (packageInfoData && packageInfoData.response) { + return packageInfoData.response; + } + }, [packageInfoData]); // Update agent policy method const updateAgentPolicy = useCallback( @@ -251,12 +235,12 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { if (routeState && routeState.onCancelUrl) { return routeState.onCancelUrl; } - return from === 'policy' + return from === 'policy' && agentPolicyId ? getHref('policy_details', { - policyId: agentPolicyId || (params as AddFromPolicyParams).policyId, + policyId: agentPolicyId, }) - : getHref('integration_details_overview', { pkgkey: (params as AddToPolicyParams).pkgkey }); - }, [agentPolicyId, params, from, getHref, routeState]); + : getHref('integration_details_overview', { pkgkey: params.pkgkey }); + }, [routeState, from, agentPolicyId, getHref, params.pkgkey]); const cancelClickHandler: ReactEventHandler = useCallback( (ev) => { @@ -305,7 +289,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { } else { history.push( getPath('policy_details', { - policyId: agentPolicy?.id || (params as AddFromPolicyParams).policyId, + policyId: agentPolicy!.id, }) ); } @@ -313,8 +297,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const fromPolicyWithoutAgentsAssigned = from === 'policy' && agentPolicy && agentCount === 0; - const fromPackageWithoutAgentsAssigned = - from === 'package' && packageInfo && agentPolicy && agentCount === 0; + const fromPackageWithoutAgentsAssigned = packageInfo && agentPolicy && agentCount === 0; const hasAgentsAssigned = agentCount && agentPolicy; @@ -379,7 +362,6 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { hasErrors, agentCount, savePackagePolicy, - doOnSaveNavigation, from, agentPolicy, packageInfo, @@ -390,7 +372,6 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { handleNavigateTo, history, getPath, - params, ]); const integrationInfo = useMemo( @@ -418,24 +399,23 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const stepSelectAgentPolicy = useMemo( () => ( <StepSelectAgentPolicy - pkgkey={(params as AddToPolicyParams).pkgkey} - updatePackageInfo={updatePackageInfo} + packageInfo={packageInfo} defaultAgentPolicyId={queryParamsPolicyId} agentPolicy={agentPolicy} updateAgentPolicy={updateAgentPolicy} - setIsLoadingSecondStep={setIsLoadingAgentPolicyStep} + setHasAgentPolicyError={setHasAgentPolicyError} /> ), - [params, updatePackageInfo, agentPolicy, updateAgentPolicy, queryParamsPolicyId] + [packageInfo, queryParamsPolicyId, agentPolicy, updateAgentPolicy] ); const extensionView = useUIExtension(packagePolicy.package?.name ?? '', 'package-policy-create'); const stepConfigurePackagePolicy = useMemo( () => - isLoadingAgentPolicyStep ? ( + isPackageInfoLoading ? ( <Loading /> - ) : agentPolicy && packageInfo ? ( + ) : packageInfo ? ( <> <StepDefinePackagePolicy agentPolicy={agentPolicy} @@ -459,8 +439,8 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { /> )} - {/* If an Agent Policy and a package has been selected, then show UI extension (if any) */} - {extensionView && packagePolicy.policy_id && packagePolicy.package?.name && ( + {/* If a package has been loaded, then show UI extension (if any) */} + {extensionView && packagePolicy.package?.name && ( <ExtensionWrapper> <extensionView.Component newPolicy={packagePolicy} @@ -473,7 +453,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { <div /> ), [ - isLoadingAgentPolicyStep, + isPackageInfoLoading, agentPolicy, packageInfo, packagePolicy, @@ -491,7 +471,6 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { title: i18n.translate('xpack.fleet.createPackagePolicy.stepConfigurePackagePolicyTitle', { defaultMessage: 'Configure integration', }), - status: !packageInfo || !agentPolicy || isLoadingAgentPolicyStep ? 'disabled' : undefined, 'data-test-subj': 'dataCollectionSetupStep', children: stepConfigurePackagePolicy, }, @@ -503,6 +482,21 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { }, ]; + // Display package error if there is one + if (packageInfoError) { + return ( + <Error + title={ + <FormattedMessage + id="xpack.fleet.createPackagePolicy.StepSelectPolicy.errorLoadingPackageTitle" + defaultMessage="Error loading package information" + /> + } + error={packageInfoError} + /> + ); + } + return ( <CreatePackagePolicyPageLayout {...layoutProps} data-test-subj="createPackagePolicy"> <EuiErrorBoundary> @@ -527,10 +521,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { <CustomEuiBottomBar data-test-subj="integrationsBottomBar"> <EuiFlexGroup justifyContent="spaceBetween" alignItems="center"> <EuiFlexItem grow={false}> - {!isLoadingAgentPolicyStep && - agentPolicy && - packageInfo && - formState === 'INVALID' ? ( + {agentPolicy && packageInfo && formState === 'INVALID' ? ( <FormattedMessage id="xpack.fleet.createPackagePolicy.errorOnSaveText" defaultMessage="Your integration policy has errors. Please fix them before saving." @@ -557,7 +548,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { <EuiButton onClick={onSubmit} isLoading={formState === 'LOADING'} - disabled={formState !== 'VALID'} + disabled={formState !== 'VALID' || hasAgentPolicyError} iconType="save" color="primary" fill diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx index e84831a3006f4..7e4896837013c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx @@ -47,7 +47,7 @@ const FormGroupResponsiveFields = styled(EuiDescribedFormGroup)` `; export const StepDefinePackagePolicy: React.FunctionComponent<{ - agentPolicy: AgentPolicy; + agentPolicy?: AgentPolicy; packageInfo: PackageInfo; packagePolicy: NewPackagePolicy; integrationToEnable?: string; @@ -92,15 +92,17 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ if (currentPkgKey !== pkgKey) { // Existing package policies on the agent policy using the package name, retrieve highest number appended to package policy name const pkgPoliciesNamePattern = new RegExp(`${packageInfo.name}-(\\d+)`); - const pkgPoliciesWithMatchingNames = (agentPolicy.package_policies as PackagePolicy[]) - .filter((ds) => Boolean(ds.name.match(pkgPoliciesNamePattern))) - .map((ds) => parseInt(ds.name.match(pkgPoliciesNamePattern)![1], 10)) - .sort((a, b) => a - b); + const pkgPoliciesWithMatchingNames = agentPolicy + ? (agentPolicy.package_policies as PackagePolicy[]) + .filter((ds) => Boolean(ds.name.match(pkgPoliciesNamePattern))) + .map((ds) => parseInt(ds.name.match(pkgPoliciesNamePattern)![1], 10)) + .sort((a, b) => a - b) + : []; updatePackagePolicy( packageToPackagePolicy( packageInfo, - agentPolicy.id, + agentPolicy?.id || '', packagePolicy.output_id, packagePolicy.namespace, `${packageInfo.name}-${ @@ -115,7 +117,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ } // If agent policy has changed, update package policy's agent policy ID and namespace - if (packagePolicy.policy_id !== agentPolicy.id) { + if (agentPolicy && packagePolicy.policy_id !== agentPolicy.id) { updatePackagePolicy({ policy_id: agentPolicy.id, namespace: agentPolicy.namespace, 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 63cf1a0c87b29..72bd829dcf61a 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,7 +26,6 @@ import { Error } from '../../../components'; import type { AgentPolicy, PackageInfo, GetAgentPoliciesResponseItem } from '../../../types'; import { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from '../../../services'; import { - useGetPackageInfoByKey, useGetAgentPolicies, sendGetOneAgentPolicy, useCapabilities, @@ -41,19 +40,17 @@ const AgentPolicyFormRow = styled(EuiFormRow)` `; export const StepSelectAgentPolicy: React.FunctionComponent<{ - pkgkey: string; - updatePackageInfo: (packageInfo: PackageInfo | undefined) => void; + packageInfo?: PackageInfo; defaultAgentPolicyId?: string; agentPolicy: AgentPolicy | undefined; updateAgentPolicy: (agentPolicy: AgentPolicy | undefined) => void; - setIsLoadingSecondStep: (isLoading: boolean) => void; + setHasAgentPolicyError: (hasError: boolean) => void; }> = ({ - pkgkey, - updatePackageInfo, + packageInfo, agentPolicy, updateAgentPolicy, - setIsLoadingSecondStep, defaultAgentPolicyId, + setHasAgentPolicyError, }) => { const { isReady: isFleetReady } = useFleetStatus(); @@ -68,14 +65,6 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ const [isCreateAgentPolicyFlyoutOpen, setIsCreateAgentPolicyFlyoutOpen] = useState<boolean>(false); - // Fetch package info - const { - data: packageInfoData, - error: packageInfoError, - isLoading: isPackageInfoLoading, - } = useGetPackageInfoByKey(pkgkey); - const isLimitedPackage = (packageInfoData && isPackageLimited(packageInfoData.response)) || false; - // Fetch agent policies info const { data: agentPoliciesData, @@ -101,18 +90,19 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ }, {}); }, [agentPolicies]); - // Update parent package state - useEffect(() => { - if (packageInfoData && packageInfoData.response) { - updatePackageInfo(packageInfoData.response); - } - }, [packageInfoData, updatePackageInfo]); + const doesAgentPolicyHaveLimitedPackage = useCallback( + (policy: AgentPolicy, pkgInfo: PackageInfo) => { + return policy + ? isPackageLimited(pkgInfo) && doesAgentPolicyAlreadyIncludePackage(policy, pkgInfo.name) + : false; + }, + [] + ); // Update parent selected agent policy state useEffect(() => { const fetchAgentPolicyInfo = async () => { if (selectedPolicyId) { - setIsLoadingSecondStep(true); const { data, error } = await sendGetOneAgentPolicy(selectedPolicyId); if (error) { setSelectedAgentPolicyError(error); @@ -125,39 +115,36 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ setSelectedAgentPolicyError(undefined); updateAgentPolicy(undefined); } - setIsLoadingSecondStep(false); }; if (!agentPolicy || selectedPolicyId !== agentPolicy.id) { fetchAgentPolicyInfo(); } - }, [selectedPolicyId, agentPolicy, updateAgentPolicy, setIsLoadingSecondStep]); + }, [selectedPolicyId, agentPolicy, updateAgentPolicy]); const agentPolicyOptions: Array<EuiComboBoxOptionOption<string>> = useMemo( () => - packageInfoData + packageInfo ? agentPolicies.map((agentConf) => { - const alreadyHasLimitedPackage = - (isLimitedPackage && - doesAgentPolicyAlreadyIncludePackage(agentConf, packageInfoData.response.name)) || - false; return { label: agentConf.name, value: agentConf.id, - disabled: alreadyHasLimitedPackage, + disabled: doesAgentPolicyHaveLimitedPackage(agentConf, packageInfo), 'data-test-subj': 'agentPolicyItem', }; }) : [], - [agentPolicies, isLimitedPackage, packageInfoData] + [agentPolicies, doesAgentPolicyHaveLimitedPackage, packageInfo] ); - const selectedAgentPolicyOption = agentPolicyOptions.find( - (option) => option.value === selectedPolicyId + const selectedAgentPolicyOption = useMemo( + () => agentPolicyOptions.find((option) => option.value === selectedPolicyId), + [agentPolicyOptions, selectedPolicyId] ); // Try to select default agent policy useEffect(() => { if (!selectedPolicyId && agentPolicies.length && agentPolicyOptions.length) { + const firstEnabledOption = agentPolicyOptions.find((option) => !option.disabled); const defaultAgentPolicy = agentPolicies.find((policy) => policy.is_default); if (defaultAgentPolicy) { const defaultAgentPolicyOption = agentPolicyOptions.find( @@ -165,25 +152,33 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ ); if (defaultAgentPolicyOption && !defaultAgentPolicyOption.disabled) { setSelectedPolicyId(defaultAgentPolicy.id); + } else { + if (firstEnabledOption) { + setSelectedPolicyId(firstEnabledOption.value); + } } + } else if (firstEnabledOption) { + setSelectedPolicyId(firstEnabledOption.value); } } }, [agentPolicies, agentPolicyOptions, selectedPolicyId]); - // Display package error if there is one - if (packageInfoError) { - return ( - <Error - title={ - <FormattedMessage - id="xpack.fleet.createPackagePolicy.StepSelectPolicy.errorLoadingPackageTitle" - defaultMessage="Error loading package information" - /> - } - error={packageInfoError} - /> - ); - } + // Bubble up any issues with agent policy selection + useEffect(() => { + if ( + selectedPolicyId && + !selectedAgentPolicyError && + selectedAgentPolicyOption && + !selectedAgentPolicyOption.disabled + ) { + setHasAgentPolicyError(false); + } else setHasAgentPolicyError(true); + }, [ + selectedAgentPolicyError, + selectedAgentPolicyOption, + selectedPolicyId, + setHasAgentPolicyError, + ]); // Display agent policies list error if there is one if (agentPoliciesError) { @@ -276,6 +271,27 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ /> ) : null } + isInvalid={Boolean( + !selectedPolicyId || + !packageInfo || + doesAgentPolicyHaveLimitedPackage( + agentPoliciesById[selectedPolicyId], + packageInfo + ) + )} + error={ + !selectedPolicyId ? ( + <FormattedMessage + id="xpack.fleet.createPackagePolicy.StepSelectPolicy.noPolicySelectedError" + defaultMessage="An agent policy is required." + /> + ) : ( + <FormattedMessage + id="xpack.fleet.createPackagePolicy.StepSelectPolicy.cannotAddLimitedIntegrationError" + defaultMessage="This integration can only be added once per agent policy." + /> + ) + } > <EuiComboBox placeholder={i18n.translate( @@ -287,7 +303,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ singleSelection={{ asPlainText: true }} isClearable={false} fullWidth={true} - isLoading={isAgentPoliciesLoading || isPackageInfoLoading} + isLoading={isAgentPoliciesLoading || !packageInfo} options={agentPolicyOptions} selectedOptions={selectedAgentPolicyOption ? [selectedAgentPolicyOption] : []} onChange={(options) => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/index.tsx index 9d9077a9abdbd..15196fadf7529 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/index.tsx @@ -15,7 +15,6 @@ import { DefaultLayout } from '../../layouts'; import { AgentPolicyListPage } from './list_page'; import { AgentPolicyDetailsPage } from './details_page'; -import { CreatePackagePolicyPage } from './create_package_policy_page'; import { EditPackagePolicyPage } from './edit_package_policy_page'; import { UpgradePackagePolicyPage } from './upgrade_package_policy_page'; @@ -32,9 +31,6 @@ export const AgentPolicyApp: React.FunctionComponent = () => { <Route path={FLEET_ROUTING_PATHS.upgrade_package_policy}> <UpgradePackagePolicyPage /> </Route> - <Route path={FLEET_ROUTING_PATHS.add_integration_from_policy}> - <CreatePackagePolicyPage /> - </Route> <Route path={FLEET_ROUTING_PATHS.policy_details}> <AgentPolicyDetailsPage /> </Route> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx index 88590ce3ce504..e2db1534597e0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx @@ -60,7 +60,7 @@ export const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploym > <FormattedMessage id="xpack.fleet.settings.userGuideLink" - defaultMessage="Fleet User Guide" + defaultMessage="Fleet and Elastic Agent Guide" /> </EuiLink> ), diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_on_prem_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_on_prem_instructions.tsx index a8cab77af447c..5005c029a7588 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_on_prem_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_on_prem_instructions.tsx @@ -185,7 +185,7 @@ export const FleetServerCommandStep = ({ > <FormattedMessage id="xpack.fleet.fleetServerSetup.setupGuideLink" - defaultMessage="Fleet User Guide" + defaultMessage="Fleet and Elastic Agent Guide" /> </EuiLink> ), @@ -755,7 +755,7 @@ export const OnPremInstructions: React.FC = () => { > <FormattedMessage id="xpack.fleet.fleetServerSetup.setupGuideLink" - defaultMessage="Fleet User Guide" + defaultMessage="Fleet and Elastic Agent Guide" /> </EuiLink> ), diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_health.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_health.tsx index f597d2c6758a8..fbac6ad74906d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_health.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_health.tsx @@ -9,6 +9,8 @@ import React from 'react'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import * as euiVars from '@elastic/eui/dist/eui_theme_light.json'; + import type { Agent } from '../../../types'; interface Props { @@ -27,7 +29,7 @@ const Status = { </EuiBadge> ), Inactive: ( - <EuiBadge color="default"> + <EuiBadge color={euiVars.default.euiColorDarkShade}> <FormattedMessage id="xpack.fleet.agentHealth.inactiveStatusText" defaultMessage="Inactive" /> </EuiBadge> ), diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx index 275d0c83da65e..74e9879936d42 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx @@ -7,19 +7,20 @@ import { euiPaletteColorBlindBehindText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import * as euiVars from '@elastic/eui/dist/eui_theme_light.json'; import type { SimplifiedAgentStatus } from '../../../types'; const visColors = euiPaletteColorBlindBehindText(); const colorToHexMap = { - // TODO - replace with variable once https://github.com/elastic/eui/issues/2731 is closed - default: '#d3dae6', + // using variables as mentioned here https://elastic.github.io/eui/#/guidelines/getting-started + default: euiVars.default.euiColorLightShade, primary: visColors[1], secondary: visColors[0], accent: visColors[2], warning: visColors[5], danger: visColors[9], - inactive: '#98A2B3', + inactive: euiVars.default.euiColorDarkShade, }; export const AGENT_STATUSES: SimplifiedAgentStatus[] = [ diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index d37cbe4c166df..c5cc1e1892eda 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -201,13 +201,15 @@ export const IntegrationsAppContext: React.FC<{ <EuiThemeProvider darkMode={isDarkMode}> <UIExtensionsContext.Provider value={extensions}> <FleetStatusProvider> - <Router history={history}> - <AgentPolicyContextProvider> - <PackageInstallProvider notifications={startServices.notifications}> - {children} - </PackageInstallProvider> - </AgentPolicyContextProvider> - </Router> + <startServices.customIntegrations.ContextProvider> + <Router history={history}> + <AgentPolicyContextProvider> + <PackageInstallProvider notifications={startServices.notifications}> + {children} + </PackageInstallProvider> + </AgentPolicyContextProvider> + </Router> + </startServices.customIntegrations.ContextProvider> </FleetStatusProvider> </UIExtensionsContext.Provider> </EuiThemeProvider> diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx index 69c70bba5be1d..bddbc4f027b4f 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx @@ -29,7 +29,7 @@ const args: Args = { release: 'ga', id: 'id', version: '1.0.0', - uiInternalPathUrl: '/', + url: '/', icons: [], integration: '', }; @@ -66,6 +66,7 @@ export const Installed = ({ width, ...props }: Args) => { install_status: 'installed', install_source: 'registry', install_started_at: '2020-01-01T00:00:00.000Z', + keep_policies_up_to_date: false, }, references: [], }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 8c7cd47e950f0..a68499dbd8dd0 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -29,7 +29,7 @@ export function PackageCard({ version, icons, integration, - uiInternalPathUrl, + url, release, }: PackageCardProps) { const betaBadgeLabel = release && release !== 'ga' ? RELEASE_BADGE_LABEL[release] : undefined; @@ -49,9 +49,10 @@ export function PackageCard({ size="xl" /> } - href={uiInternalPathUrl} + href={url} betaBadgeLabel={betaBadgeLabel} betaBadgeTooltipContent={betaBadgeLabelTooltipContent} + target={url.startsWith('http') || url.startsWith('https') ? '_blank' : undefined} /> ); } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx index f3bf7106fabcf..8349ec90ce3ba 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx @@ -44,6 +44,7 @@ const savedObject: SavedObject<Installation> = { install_status: 'installed', install_source: 'registry', install_started_at: '2020-01-01T00:00:00.000Z', + keep_policies_up_to_date: false, }, references: [], }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx index 8424fecad08cd..8716d78dfb7bd 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx @@ -7,3 +7,4 @@ export { UpdateIcon } from './update_icon'; export { IntegrationAgentPolicyCount } from './integration_agent_policy_count'; export { IconPanel, LoadingIconPanel } from './icon_panel'; +export { KeepPoliciesUpToDateSwitch } from './keep_policies_up_to_date_switch'; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/keep_policies_up_to_date_switch.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/keep_policies_up_to_date_switch.tsx new file mode 100644 index 0000000000000..751282cc42288 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/keep_policies_up_to_date_switch.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSwitch, EuiSpacer, EuiText, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; + +interface Props { + checked: boolean; + onChange: () => void; +} + +export const KeepPoliciesUpToDateSwitch: React.FunctionComponent<Props> = ({ + checked, + onChange, +}) => ( + <> + <EuiSwitch + label={i18n.translate( + 'xpack.fleet.integrations.settings.keepIntegrationPoliciesUpToDateLabel', + { defaultMessage: 'Keep integration policies up to date automatically' } + )} + checked={checked} + onChange={onChange} + /> + <EuiSpacer size="s" /> + <EuiText color="subdued" size="xs"> + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem grow={false}> + <EuiIcon type="iInCircle" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <FormattedMessage + id="xpack.fleet.integrations.settings.keepIntegrationPoliciesUpToDateDescription" + defaultMessage="When enabled, Fleet will attempt to upgrade and deploy integration policies automatically" + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiText> + </> +); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/details.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/details.tsx index 467dae12fa583..5ef06e734f580 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/details.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/details.tsx @@ -18,6 +18,10 @@ import { } from '@elastic/eui'; import type { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; +import styled, { useTheme } from 'styled-components'; + +import type { EuiTheme } from '../../../../../../../../../../../src/plugins/kibana_react/common'; + import type { PackageInfo, PackageSpecCategory, @@ -28,13 +32,21 @@ import { entries } from '../../../../../types'; import { useGetCategories } from '../../../../../hooks'; import { AssetTitleMap, DisplayedAssets, ServiceTitleMap } from '../../../constants'; +import { + withSuspense, + LazyReplacementCard, +} from '../../../../../../../../../../../src/plugins/custom_integrations/public'; + import { NoticeModal } from './notice_modal'; +const ReplacementCard = withSuspense(LazyReplacementCard); + interface Props { packageInfo: PackageInfo; } export const Details: React.FC<Props> = memo(({ packageInfo }) => { + const theme = useTheme() as EuiTheme; const { data: categoriesData, isLoading: isLoadingCategories } = useGetCategories(); const packageCategories: string[] = useMemo(() => { if (!isLoadingCategories && categoriesData && categoriesData.response) { @@ -163,6 +175,23 @@ export const Details: React.FC<Props> = memo(({ packageInfo }) => { toggleNoticeModal, ]); + const Replacements = styled(EuiFlexItem)` + margin: 0; + + & .euiAccordion { + padding-top: ${parseInt(theme.eui.euiSizeL, 10) * 2}px; + + &::before { + content: ''; + display: block; + border-top: 1px solid ${theme.eui.euiColorLightShade}; + position: relative; + top: -${theme.eui.euiSizeL}; + margin: 0 ${theme.eui.euiSizeXS}; + } + } + `; + return ( <> <EuiPortal> @@ -181,6 +210,9 @@ export const Details: React.FC<Props> = memo(({ packageInfo }) => { <EuiFlexItem> <EuiDescriptionList type="column" compressed listItems={listItems} /> </EuiFlexItem> + <Replacements> + <ReplacementCard eprPackageName={packageInfo.name} /> + </Replacements> </EuiFlexGroup> </> ); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx index 42eb68099970a..304bdd621b1b2 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx @@ -217,7 +217,7 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps }), render(_version, { agentPolicy, packagePolicy }) { return ( - <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexGroup gutterSize="s" alignItems="center" wrap={true}> <EuiFlexItem grow={false}> <EuiText size="s" className="eui-textNoWrap"> <FormattedMessage diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 07c95e0d77ec7..185ae10bcafd2 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { memo, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import semverLt from 'semver/functions/lt'; +import { uniq } from 'lodash'; import { EuiCallOut, @@ -29,8 +30,16 @@ import { useGetPackageInstallStatus, useLink, sendUpgradePackagePolicyDryRun, + sendUpdatePackage, + useStartServices, } from '../../../../../hooks'; -import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; +import { + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + AUTO_UPDATE_PACKAGES, + DEFAULT_PACKAGES, +} from '../../../../../constants'; + +import { KeepPoliciesUpToDateSwitch } from '../components'; import { InstallButton } from './install_button'; import { UpdateButton } from './update_button'; @@ -85,7 +94,7 @@ interface Props { } export const SettingsPage: React.FC<Props> = memo(({ packageInfo }: Props) => { - const { name, title, removable, latestVersion, version } = packageInfo; + const { name, title, removable, latestVersion, version, keepPoliciesUpToDate } = packageInfo; const [dryRunData, setDryRunData] = useState<UpgradePackagePolicyDryRunResponse | null>(); const [isUpgradingPackagePolicies, setIsUpgradingPackagePolicies] = useState<boolean>(false); const getPackageInstallStatus = useGetPackageInstallStatus(); @@ -95,6 +104,67 @@ export const SettingsPage: React.FC<Props> = memo(({ packageInfo }: Props) => { kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${name}`, }); + const { notifications } = useStartServices(); + + const shouldShowKeepPoliciesUpToDateSwitch = useMemo(() => { + const packages = [...DEFAULT_PACKAGES, ...AUTO_UPDATE_PACKAGES]; + + const packageNames = uniq(packages.map((pkg) => pkg.name)); + + return packageNames.includes(name); + }, [name]); + + const [keepPoliciesUpToDateSwitchValue, setKeepPoliciesUpToDateSwitchValue] = useState<boolean>( + keepPoliciesUpToDate ?? false + ); + + const handleKeepPoliciesUpToDateSwitchChange = useCallback(() => { + const saveKeepPoliciesUpToDate = async () => { + try { + setKeepPoliciesUpToDateSwitchValue((prev) => !prev); + + await sendUpdatePackage(`${packageInfo.name}-${packageInfo.version}`, { + keepPoliciesUpToDate: !keepPoliciesUpToDateSwitchValue, + }); + + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.fleet.integrations.integrationSaved', { + defaultMessage: 'Integration settings saved', + }), + text: !keepPoliciesUpToDateSwitchValue + ? i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateEnabledSuccess', { + defaultMessage: + 'Fleet will automatically keep integration policies up to date for {title}', + values: { title }, + }) + : i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateDisabledSuccess', { + defaultMessage: + 'Fleet will not automatically keep integration policies up to date for {title}', + values: { title }, + }), + }); + } catch (error) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.integrations.integrationSavedError', { + defaultMessage: 'Error saving integration settings', + }), + toastMessage: i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateError', { + defaultMessage: 'Error saving integration settings for {title}', + values: { title }, + }), + }); + } + }; + + saveKeepPoliciesUpToDate(); + }, [ + keepPoliciesUpToDateSwitchValue, + notifications.toasts, + packageInfo.name, + packageInfo.version, + title, + ]); + const { status: installationStatus, version: installedVersion } = getPackageInstallStatus(name); const packageHasUsages = !!packagePoliciesData?.total; @@ -199,6 +269,16 @@ export const SettingsPage: React.FC<Props> = memo(({ packageInfo }: Props) => { </tr> </tbody> </table> + {shouldShowKeepPoliciesUpToDateSwitch && ( + <> + <KeepPoliciesUpToDateSwitch + checked={keepPoliciesUpToDateSwitchValue} + onChange={handleKeepPoliciesUpToDateSwitchChange} + /> + <EuiSpacer size="l" /> + </> + )} + {(updateAvailable || isUpgradingPackagePolicies) && ( <> <UpdatesAvailableMsg latestVersion={latestVersion} /> diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx index edb79b1f2c17b..62225d14d3857 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx @@ -89,7 +89,7 @@ function mapToCard( title: item.title, version: 'version' in item ? item.version || '' : '', release: 'release' in item ? item.release : undefined, - uiInternalPathUrl, + url: uiInternalPathUrl, }; } diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/missing_fleet_server_host_callout.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/missing_fleet_server_host_callout.tsx index 169ff86b40c88..c390b50c498fb 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/missing_fleet_server_host_callout.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/missing_fleet_server_host_callout.tsx @@ -30,7 +30,7 @@ export const MissingFleetServerHostCallout: React.FunctionComponent = () => { <EuiLink href={docLinks.links.fleet.guide} target="_blank" external> <FormattedMessage id="xpack.fleet.agentEnrollment.missingFleetHostGuideLink" - defaultMessage="Fleet User Guide" + defaultMessage="Fleet and Elastic Agent Guide" /> </EuiLink> ), diff --git a/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx index 9bedfca0d3bca..d10fd8336a37f 100644 --- a/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx @@ -382,7 +382,7 @@ export const SettingFlyout: React.FunctionComponent<Props> = ({ onClose }) => { > <FormattedMessage id="xpack.fleet.settings.userGuideLink" - defaultMessage="Fleet User Guide" + defaultMessage="Fleet and Elastic Agent Guide" /> </EuiLink> ), diff --git a/x-pack/plugins/fleet/public/constants/index.ts b/x-pack/plugins/fleet/public/constants/index.ts index a0e88bc58726a..32dd732c53dec 100644 --- a/x-pack/plugins/fleet/public/constants/index.ts +++ b/x-pack/plugins/fleet/public/constants/index.ts @@ -19,6 +19,9 @@ export { // Fleet Server index AGENTS_INDEX, ENROLLMENT_API_KEYS_INDEX, + // Preconfiguration + AUTO_UPDATE_PACKAGES, + DEFAULT_PACKAGES, } from '../../common/constants'; export * from './page_paths'; diff --git a/x-pack/plugins/fleet/public/constants/page_paths.ts b/x-pack/plugins/fleet/public/constants/page_paths.ts index e430e58d297f9..0673d50ec9485 100644 --- a/x-pack/plugins/fleet/public/constants/page_paths.ts +++ b/x-pack/plugins/fleet/public/constants/page_paths.ts @@ -26,7 +26,6 @@ export type DynamicPage = | 'integration_details_custom' | 'integration_policy_edit' | 'policy_details' - | 'add_integration_from_policy' | 'add_integration_to_policy' | 'edit_integration' | 'upgrade_package_policy' @@ -56,8 +55,6 @@ export const FLEET_ROUTING_PATHS = { policy_details_settings: '/policies/:policyId/settings', edit_integration: '/policies/:policyId/edit-integration/:packagePolicyId', upgrade_package_policy: '/policies/:policyId/upgrade-package-policy/:packagePolicyId', - // TODO: Review uses and remove if it is no longer used or linked to in any UX flows - add_integration_from_policy: '/policies/:policyId/add-integration', enrollment_tokens: '/enrollment-tokens', data_streams: '/data-streams', @@ -127,11 +124,6 @@ export const pagePathGetters: { FLEET_BASE_PATH, `/policies/${policyId}${tabId ? `/${tabId}` : ''}`, ], - // TODO: This might need to be removed because we do not have a way to pick an integration in line anymore - add_integration_from_policy: ({ policyId }) => [ - FLEET_BASE_PATH, - `/policies/${policyId}/add-integration`, - ], add_integration_to_policy: ({ pkgkey, integration, agentPolicyId }) => [ FLEET_BASE_PATH, // prettier-ignore diff --git a/x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.ts b/x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.ts index a3c1fea5e744f..ac53badc2446d 100644 --- a/x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.ts +++ b/x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.ts @@ -10,6 +10,7 @@ import type { CustomIntegration, IntegrationCategory, } from '../../../../../src/plugins/custom_integrations/common'; +import { filterCustomIntegrations } from '../../../../../src/plugins/custom_integrations/public'; // Export this as a utility to find replacements for a package (e.g. in the overview-page for an EPR package) function findReplacementsForEprPackage( @@ -20,9 +21,7 @@ function findReplacementsForEprPackage( if (release === 'ga') { return []; } - return replacements.filter((customIntegration: CustomIntegration) => { - return customIntegration.eprOverlap === packageName; - }); + return filterCustomIntegrations(replacements, { eprPackageName: packageName }); } export function useMergeEprPackagesWithReplacements( diff --git a/x-pack/plugins/fleet/public/hooks/use_request/epm.ts b/x-pack/plugins/fleet/public/hooks/use_request/epm.ts index d6764aac7de00..a7078dd3a3f91 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/epm.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/epm.ts @@ -17,6 +17,8 @@ import type { GetInfoResponse, InstallPackageResponse, DeletePackageResponse, + UpdatePackageRequest, + UpdatePackageResponse, } from '../../types'; import type { GetStatsResponse } from '../../../common'; @@ -113,3 +115,11 @@ export const sendRemovePackage = (pkgkey: string) => { method: 'delete', }); }; + +export const sendUpdatePackage = (pkgkey: string, body: UpdatePackageRequest['body']) => { + return sendRequest<UpdatePackageResponse>({ + path: epmRouteService.getUpdatePath(pkgkey), + method: 'put', + body, + }); +}; diff --git a/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts b/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts index 5f3ee5c188b45..f78fe58a6ad88 100644 --- a/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts +++ b/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts @@ -26,5 +26,6 @@ export const createStartDepsMock = (): MockedFleetStartDeps => { return { data: dataPluginMock.createStartContract(), navigation: navigationPluginMock.createStartContract(), + customIntegrations: customIntegrationsMock.createStart(), }; }; diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index d7cc332910dc2..d23bfcfe7b888 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -16,8 +16,12 @@ import { i18n } from '@kbn/i18n'; import type { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; +import type { + CustomIntegrationsStart, + CustomIntegrationsSetup, +} from 'src/plugins/custom_integrations/public'; + import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../src/core/public'; -import type { CustomIntegrationsSetup } from '../../../../src/plugins/custom_integrations/public'; import type { DataPublicPluginSetup, @@ -76,6 +80,7 @@ export interface FleetSetupDeps { export interface FleetStartDeps { data: DataPublicPluginStart; navigation: NavigationPublicPluginStart; + customIntegrations: CustomIntegrationsStart; } export interface FleetStartServices extends CoreStart, FleetStartDeps { diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index 2328ca826da71..3ff0a760b5882 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -128,6 +128,8 @@ export { Installable, RegistryRelease, PackageSpecCategory, + UpdatePackageRequest, + UpdatePackageResponse, } from '../../common'; export * from './intra_app_route_state'; diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 2ce457242c6b5..bfb1f3ec433f2 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -58,6 +58,7 @@ export { // Preconfiguration PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, PRECONFIGURATION_LATEST_KEYWORD, + AUTO_UPDATE_PACKAGES, } from '../../common'; export { diff --git a/x-pack/plugins/fleet/server/index.test.ts b/x-pack/plugins/fleet/server/index.test.ts index 659c7d2761d02..aeb64e66e2dd4 100644 --- a/x-pack/plugins/fleet/server/index.test.ts +++ b/x-pack/plugins/fleet/server/index.test.ts @@ -6,9 +6,12 @@ */ import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; +import { configDeprecationsMock } from '../../../../src/core/server/mocks'; import { config } from '.'; +const deprecationContext = configDeprecationsMock.createContext(); + const applyConfigDeprecations = (settings: Record<string, any> = {}) => { if (!config.deprecations) { throw new Error('Config is not valid no deprecations'); @@ -20,6 +23,7 @@ const applyConfigDeprecations = (settings: Record<string, any> = {}) => { deprecations.map((deprecation) => ({ deprecation, path: '', + context: deprecationContext, })), () => ({ message }) => diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 16d583f8a8d1f..2324d1a423bfc 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -22,6 +22,7 @@ import type { BulkInstallPackagesResponse, IBulkInstallPackageHTTPError, GetStatsResponse, + UpdatePackageResponse, } from '../../../common'; import type { GetCategoriesRequestSchema, @@ -33,6 +34,7 @@ import type { DeletePackageRequestSchema, BulkUpgradePackagesFromRegistryRequestSchema, GetStatsRequestSchema, + UpdatePackageRequestSchema, } from '../../types'; import { bulkInstallPackages, @@ -53,6 +55,7 @@ import { licenseService } from '../../services'; import { getArchiveEntry } from '../../services/epm/archive/cache'; import { getAsset } from '../../services/epm/archive/storage'; import { getPackageUsageStats } from '../../services/epm/packages/get'; +import { updatePackage } from '../../services/epm/packages/update'; export const getCategoriesHandler: RequestHandler< undefined, @@ -201,6 +204,28 @@ export const getInfoHandler: RequestHandler<TypeOf<typeof GetInfoRequestSchema.p } }; +export const updatePackageHandler: RequestHandler< + TypeOf<typeof UpdatePackageRequestSchema.params>, + unknown, + TypeOf<typeof UpdatePackageRequestSchema.body> +> = async (context, request, response) => { + try { + const { pkgkey } = request.params; + const savedObjectsClient = context.core.savedObjects.client; + + const { pkgName } = splitPkgKey(pkgkey); + + const res = await updatePackage({ savedObjectsClient, pkgName, ...request.body }); + const body: UpdatePackageResponse = { + response: res, + }; + + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; + export const getStatsHandler: RequestHandler<TypeOf<typeof GetStatsRequestSchema.params>> = async ( context, request, diff --git a/x-pack/plugins/fleet/server/routes/epm/index.ts b/x-pack/plugins/fleet/server/routes/epm/index.ts index 40316bd102e5f..684547dc1862c 100644 --- a/x-pack/plugins/fleet/server/routes/epm/index.ts +++ b/x-pack/plugins/fleet/server/routes/epm/index.ts @@ -18,6 +18,7 @@ import { DeletePackageRequestSchema, BulkUpgradePackagesFromRegistryRequestSchema, GetStatsRequestSchema, + UpdatePackageRequestSchema, } from '../../types'; import { @@ -31,6 +32,7 @@ import { deletePackageHandler, bulkInstallPackagesFromRegistryHandler, getStatsHandler, + updatePackageHandler, } from './handlers'; const MAX_FILE_SIZE_BYTES = 104857600; // 100MB @@ -90,6 +92,15 @@ export const registerRoutes = (router: IRouter) => { getInfoHandler ); + router.put( + { + path: EPM_API_ROUTES.INFO_PATTERN, + validate: UpdatePackageRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + updatePackageHandler + ); + router.post( { path: EPM_API_ROUTES.INSTALL_FROM_REGISTRY_PATTERN, diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 83188e0047044..ac5ca401da000 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -44,6 +44,7 @@ import { } from './migrations/to_v7_13_0'; import { migratePackagePolicyToV7140, migrateInstallationToV7140 } from './migrations/to_v7_14_0'; import { migratePackagePolicyToV7150 } from './migrations/to_v7_15_0'; +import { migrateInstallationToV7160 } from './migrations/to_v7_16_0'; /* * Saved object types and mappings @@ -298,6 +299,7 @@ const getSavedObjectTypes = ( version: { type: 'keyword' }, internal: { type: 'boolean' }, removable: { type: 'boolean' }, + keep_policies_up_to_date: { type: 'boolean', index: false }, es_index_patterns: { enabled: false, type: 'object', @@ -332,6 +334,7 @@ const getSavedObjectTypes = ( migrations: { '7.14.0': migrateInstallationToV7140, '7.14.1': migrateInstallationToV7140, + '7.16.0': migrateInstallationToV7160, }, }, [ASSETS_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_16_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_16_0.ts new file mode 100644 index 0000000000000..7d12c550ec406 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_16_0.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectMigrationFn } from 'kibana/server'; + +import type { Installation } from '../../../common'; +import { AUTO_UPDATE_PACKAGES, DEFAULT_PACKAGES } from '../../../common'; + +export const migrateInstallationToV7160: SavedObjectMigrationFn<Installation, Installation> = ( + installationDoc, + migrationContext +) => { + const updatedInstallationDoc = installationDoc; + + if ( + [...AUTO_UPDATE_PACKAGES, ...DEFAULT_PACKAGES].some( + (pkg) => pkg.name === updatedInstallationDoc.attributes.name + ) + ) { + updatedInstallationDoc.attributes.keep_policies_up_to_date = true; + } + + return updatedInstallationDoc; +}; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 1bbbb1bb9b6a2..9f66b5dd379ec 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -7,7 +7,12 @@ import type { ElasticsearchClient, SavedObject, SavedObjectsClientContract } from 'src/core/server'; -import { MAX_TIME_COMPLETE_INSTALL, ASSETS_SAVED_OBJECT_TYPE } from '../../../../common'; +import { + MAX_TIME_COMPLETE_INSTALL, + ASSETS_SAVED_OBJECT_TYPE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + SO_SEARCH_LIMIT, +} from '../../../../common'; import type { InstallablePackage, InstallSource, PackageAssetReference } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; import type { AssetReference, Installation, InstallType } from '../../../types'; @@ -22,6 +27,8 @@ import { installIlmForDataStream } from '../elasticsearch/datastream_ilm/install import { saveArchiveEntries } from '../archive/storage'; import { ConcurrentInstallOperationError } from '../../../errors'; +import { packagePolicyService } from '../..'; + import { createInstallation, saveKibanaAssetsRefs, updateVersion } from './install'; import { deleteKibanaSavedObjectsAssets } from './remove'; @@ -192,11 +199,27 @@ export async function _installPackage({ // update to newly installed version when all assets are successfully installed if (installedPkg) await updateVersion(savedObjectsClient, pkgName, pkgVersion); - await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { - install_version: pkgVersion, - install_status: 'installed', - package_assets: packageAssetRefs, - }); + const updatedPackage = await savedObjectsClient.update<Installation>( + PACKAGES_SAVED_OBJECT_TYPE, + pkgName, + { + install_version: pkgVersion, + install_status: 'installed', + package_assets: packageAssetRefs, + } + ); + + // If the package is flagged with the `keep_policies_up_to_date` flag, upgrade its + // associated package policies after installation + if (updatedPackage.attributes.keep_policies_up_to_date) { + const policyIdsToUpgrade = await packagePolicyService.listIds(savedObjectsClient, { + page: 1, + perPage: SO_SEARCH_LIMIT, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`, + }); + + await packagePolicyService.upgrade(savedObjectsClient, esClient, policyIdsToUpgrade.items); + } return [ ...installedKibanaAssetsRefs, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 0e23981b95fcd..d4f988e5fba8c 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -137,6 +137,7 @@ export async function getPackageInfo(options: { assets: Registry.groupPathsByService(paths || []), removable: !isUnremovablePackage(pkgName), notice: Registry.getNoticePath(paths || []), + keepPoliciesUpToDate: savedObject?.attributes.keep_policies_up_to_date ?? false, }; const updated = { ...packageInfo, ...additions }; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get_install_type.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/get_install_type.test.ts index 155cd67e60287..6bc962165f1d2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get_install_type.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get_install_type.test.ts @@ -28,6 +28,7 @@ const mockInstallation: SavedObject<Installation> = { install_version: '1.0.0', install_started_at: new Date().toISOString(), install_source: 'registry', + keep_policies_up_to_date: false, }, }; const mockInstallationUpdateFail: SavedObject<Installation> = { @@ -46,6 +47,7 @@ const mockInstallationUpdateFail: SavedObject<Installation> = { install_version: '1.0.1', install_started_at: new Date().toISOString(), install_source: 'registry', + keep_policies_up_to_date: false, }, }; 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 bd1968f03c263..e71ef5e002884 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -457,6 +457,7 @@ export async function createInstallation(options: { install_status: 'installing', install_started_at: new Date().toISOString(), install_source: installSource, + keep_policies_up_to_date: false, }, { id: pkgName, overwrite: true } ); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/update.ts b/x-pack/plugins/fleet/server/services/epm/packages/update.ts new file mode 100644 index 0000000000000..84c756983fa07 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/update.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsClientContract } from 'kibana/server'; +import type { TypeOf } from '@kbn/config-schema'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; +import type { Installation, UpdatePackageRequestSchema } from '../../../types'; +import { IngestManagerError } from '../../../errors'; + +import { getInstallationObject, getPackageInfo } from './get'; + +export async function updatePackage( + options: { + savedObjectsClient: SavedObjectsClientContract; + pkgName: string; + keepPoliciesUpToDate?: boolean; + } & TypeOf<typeof UpdatePackageRequestSchema.body> +) { + const { savedObjectsClient, pkgName, keepPoliciesUpToDate } = options; + const installedPackage = await getInstallationObject({ savedObjectsClient, pkgName }); + + if (!installedPackage) { + throw new IngestManagerError(`package ${pkgName} is not installed`); + } + + await savedObjectsClient.update<Installation>(PACKAGES_SAVED_OBJECT_TYPE, installedPackage.id, { + keep_policies_up_to_date: keepPoliciesUpToDate ?? false, + }); + + const packageInfo = await getPackageInfo({ + savedObjectsClient, + pkgName, + pkgVersion: installedPackage.attributes.version, + }); + + return packageInfo; +} diff --git a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts index 568aafddecbad..dfca8511fd84c 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { appContextService, licenseService } from '../../'; +import { appContextService } from '../../'; // from https://github.com/elastic/package-registry#docker (maybe from OpenAPI one day) // the unused variables cause a TS warning about unused values @@ -32,16 +32,9 @@ const getDefaultRegistryUrl = (): string => { export const getRegistryUrl = (): string => { const customUrl = appContextService.getConfig()?.registryUrl; - const isEnterprise = licenseService.isEnterprise(); - - if (customUrl && isEnterprise) { - return customUrl; - } if (customUrl) { - appContextService - .getLogger() - .warn('Enterprise license is required to use a custom registry url.'); + return customUrl; } return getDefaultRegistryUrl(); diff --git a/x-pack/plugins/fleet/server/services/fleet_server/index.ts b/x-pack/plugins/fleet/server/services/fleet_server/index.ts index 733d962a86e9e..0d386b9ba4995 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server/index.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server/index.ts @@ -50,22 +50,25 @@ export async function startFleetServerSetup() { _onResolve = resolve; }); const logger = appContextService.getLogger(); + + // Check for security if (!appContextService.hasSecurity()) { // Fleet will not work if security is not enabled logger?.warn('Fleet requires the security plugin to be enabled.'); return; } + // Log information about custom registry URL + const customUrl = appContextService.getConfig()?.registryUrl; + if (customUrl) { + logger.info( + `Custom registry url is an experimental feature and is unsupported. Using custom registry at ${customUrl}` + ); + } + try { // We need licence to be initialized before using the SO service. await licenseService.getLicenseInformation$()?.pipe(first())?.toPromise(); - - const customUrl = appContextService.getConfig()?.registryUrl; - const isEnterprise = licenseService.isEnterprise(); - if (customUrl && isEnterprise) { - logger.info('Custom registry url is an experimental feature and is unsupported.'); - } - await runFleetServerMigration(); _isFleetServerSetup = true; } catch (err) { diff --git a/x-pack/plugins/fleet/server/services/managed_package_policies.test.ts b/x-pack/plugins/fleet/server/services/managed_package_policies.test.ts new file mode 100644 index 0000000000000..a53b1fe648905 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/managed_package_policies.test.ts @@ -0,0 +1,106 @@ +/* + * 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 { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; + +import { upgradeManagedPackagePolicies } from './managed_package_policies'; +import { packagePolicyService } from './package_policy'; +import { getPackageInfo } from './epm/packages'; + +jest.mock('./package_policy'); +jest.mock('./epm/packages'); +jest.mock('./app_context', () => { + return { + ...jest.requireActual('./app_context'), + appContextService: { + getLogger: jest.fn(() => { + return { debug: jest.fn() }; + }), + }, + }; +}); + +describe('managed package policies', () => { + afterEach(() => { + (packagePolicyService.get as jest.Mock).mockReset(); + (getPackageInfo as jest.Mock).mockReset(); + }); + + it('should not upgrade policies for non-managed package', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const soClient = savedObjectsClientMock.create(); + + (packagePolicyService.get as jest.Mock).mockImplementationOnce( + (savedObjectsClient: any, id: string) => { + return { + id, + inputs: {}, + version: '', + revision: 1, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + package: { + name: 'non-managed-package', + title: 'Non-Managed Package', + version: '0.0.1', + }, + }; + } + ); + + (getPackageInfo as jest.Mock).mockImplementationOnce( + ({ savedObjectsClient, pkgName, pkgVersion }) => ({ + name: pkgName, + version: pkgVersion, + keepPoliciesUpToDate: false, + }) + ); + + await upgradeManagedPackagePolicies(soClient, esClient, ['non-managed-package-id']); + + expect(packagePolicyService.upgrade).not.toBeCalled(); + }); + + it('should upgrade policies for managed package', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const soClient = savedObjectsClientMock.create(); + + (packagePolicyService.get as jest.Mock).mockImplementationOnce( + (savedObjectsClient: any, id: string) => { + return { + id, + inputs: {}, + version: '', + revision: 1, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + package: { + name: 'managed-package', + title: 'Managed Package', + version: '0.0.1', + }, + }; + } + ); + + (getPackageInfo as jest.Mock).mockImplementationOnce( + ({ savedObjectsClient, pkgName, pkgVersion }) => ({ + name: pkgName, + version: pkgVersion, + keepPoliciesUpToDate: true, + }) + ); + + await upgradeManagedPackagePolicies(soClient, esClient, ['managed-package-id']); + + expect(packagePolicyService.upgrade).toBeCalledWith(soClient, esClient, ['managed-package-id']); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/managed_package_policies.ts b/x-pack/plugins/fleet/server/services/managed_package_policies.ts new file mode 100644 index 0000000000000..73f85525f4c60 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/managed_package_policies.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 type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; + +import { AUTO_UPDATE_PACKAGES } from '../../common'; + +import { appContextService } from './app_context'; +import { getPackageInfo } from './epm/packages'; +import { packagePolicyService } from './package_policy'; + +/** + * Upgrade any package policies for packages installed through setup that are denoted as `AUTO_UPGRADE` packages + * or have the `keep_policies_up_to_date` flag set to `true` + */ +export const upgradeManagedPackagePolicies = async ( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + packagePolicyIds: string[] +) => { + const policyIdsToUpgrade: string[] = []; + + for (const packagePolicyId of packagePolicyIds) { + const packagePolicy = await packagePolicyService.get(soClient, packagePolicyId); + + if (!packagePolicy || !packagePolicy.package) { + continue; + } + + const packageInfo = await getPackageInfo({ + savedObjectsClient: soClient, + pkgName: packagePolicy.package.name, + pkgVersion: packagePolicy.package.version, + }); + + const shouldUpgradePolicies = + AUTO_UPDATE_PACKAGES.some((pkg) => pkg.name === packageInfo.name) || + packageInfo.keepPoliciesUpToDate; + + if (shouldUpgradePolicies) { + policyIdsToUpgrade.push(packagePolicy.id); + } + } + + if (policyIdsToUpgrade.length) { + appContextService + .getLogger() + .debug( + `Upgrading ${policyIdsToUpgrade.length} package policies: ${policyIdsToUpgrade.join(', ')}` + ); + + await packagePolicyService.upgrade(soClient, esClient, policyIdsToUpgrade); + } +}; diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 31f1440135436..c04134e97b415 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -1085,7 +1085,98 @@ describe('Package policy service', () => { }); describe('overridePackageInputs', () => { - it('should override variable in base package policy', () => { + describe('when variable is already defined', () => { + it('preserves original variable value without overwriting', () => { + const basePackagePolicy: NewPackagePolicy = { + name: 'base-package-policy', + description: 'Base Package Policy', + namespace: 'default', + enabled: true, + policy_id: 'xxxx', + output_id: 'xxxx', + package: { + name: 'test-package', + title: 'Test Package', + version: '0.0.1', + }, + inputs: [ + { + type: 'logs', + policy_template: 'template_1', + enabled: true, + vars: { + path: { + type: 'text', + value: ['/var/log/logfile.log'], + }, + }, + streams: [], + }, + ], + }; + + const packageInfo: PackageInfo = { + name: 'test-package', + description: 'Test Package', + title: 'Test Package', + version: '0.0.1', + latestVersion: '0.0.1', + release: 'experimental', + format_version: '1.0.0', + owner: { github: 'elastic/fleet' }, + policy_templates: [ + { + name: 'template_1', + title: 'Template 1', + description: 'Template 1', + inputs: [ + { + type: 'logs', + title: 'Log', + description: 'Log Input', + vars: [ + { + name: 'path', + type: 'text', + }, + ], + }, + ], + }, + ], + // @ts-ignore + assets: {}, + }; + + const inputsOverride: NewPackagePolicyInput[] = [ + { + type: 'logs', + enabled: true, + streams: [], + vars: { + path: { + type: 'text', + value: '/var/log/new-logfile.log', + }, + }, + }, + ]; + + const result = overridePackageInputs( + basePackagePolicy, + packageInfo, + // TODO: Update this type assertion when the `InputsOverride` type is updated such + // that it no longer causes unresolvable type errors when used directly + inputsOverride as InputsOverride[], + false + ); + expect(result.inputs[0]?.vars?.path.value).toEqual(['/var/log/logfile.log']); + }); + }); + }); + + describe('when variable is undefined in original object', () => { + it('adds the variable definition to the resulting object', () => { const basePackagePolicy: NewPackagePolicy = { name: 'base-package-policy', description: 'Base Package Policy', @@ -1138,6 +1229,10 @@ describe('Package policy service', () => { name: 'path', type: 'text', }, + { + name: 'path_2', + type: 'text', + }, ], }, ], @@ -1157,6 +1252,10 @@ describe('Package policy service', () => { type: 'text', value: '/var/log/new-logfile.log', }, + path_2: { + type: 'text', + value: '/var/log/custom.log', + }, }, }, ]; @@ -1169,7 +1268,7 @@ describe('Package policy service', () => { inputsOverride as InputsOverride[], false ); - expect(result.inputs[0]?.vars?.path.value).toBe('/var/log/new-logfile.log'); + expect(result.inputs[0]?.vars?.path_2.value).toEqual('/var/log/custom.log'); }); }); }); diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 9b02d6eaff495..93f04a55d233b 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -980,7 +980,7 @@ export function overridePackageInputs( ({ name }) => name === input.policy_template ); - // Ignore any policy template removes in the new package version + // Ignore any policy templates removed in the new package version if (!policyTemplate) { return false; } @@ -1000,7 +1000,7 @@ export function overridePackageInputs( // If there's no corresponding input on the original package policy, just // take the override value from the new package as-is. This case typically - // occurs when inputs or package policies are added/removed between versions. + // occurs when inputs or package policy templates are added/removed between versions. if (originalInput === undefined) { inputs.push(override as NewPackagePolicyInput); continue; @@ -1092,7 +1092,14 @@ function deepMergeVars(original: any, override: any): any { for (const { name, ...overrideVal } of overrideVars) { const originalVar = original.vars[name]; + result.vars[name] = { ...originalVar, ...overrideVal }; + + // Ensure that any value from the original object is persisted on the newly merged resulting object, + // even if we merge other data about the given variable + if (originalVar?.value) { + result.vars[name].value = originalVar.value; + } } return result; diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 43887bc2787f4..d0ae995358632 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -137,6 +137,7 @@ jest.mock('./package_policy', () => ({ ...jest.requireActual('./package_policy'), packagePolicyService: { getByIDs: jest.fn().mockReturnValue([]), + listIds: jest.fn().mockReturnValue({ items: [] }), create(soClient: any, esClient: any, newPackagePolicy: NewPackagePolicy) { return { id: 'mocked', @@ -144,6 +145,12 @@ jest.mock('./package_policy', () => ({ ...newPackagePolicy, }; }, + get(soClient: any, id: string) { + return { + id: 'mocked', + version: 'mocked', + }; + }, }, })); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 30c5c27c68916..a444f8bdaa4da 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -35,6 +35,7 @@ import { agentPolicyService, addPackageToAgentPolicy } from './agent_policy'; import type { InputsOverride } from './package_policy'; import { overridePackageInputs } from './package_policy'; import { appContextService } from './app_context'; +import { upgradeManagedPackagePolicies } from './managed_package_policies'; import { outputService } from './output'; interface PreconfigurationResult { @@ -313,6 +314,17 @@ export async function ensurePreconfiguredPackagesAndPolicies( } } + try { + const fulfilledPolicyPackagePolicyIds = fulfilledPolicies.flatMap<string>( + ({ policy }) => policy?.package_policies as string[] + ); + + await upgradeManagedPackagePolicies(soClient, esClient, fulfilledPolicyPackagePolicyIds); + // Swallow errors that occur when upgrading + } catch (error) { + appContextService.getLogger().error(error); + } + return { policies: fulfilledPolicies.map((p) => p.policy diff --git a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts index 25f1e766a7476..918def62a9d0e 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts @@ -35,6 +35,15 @@ export const GetInfoRequestSchema = { }), }; +export const UpdatePackageRequestSchema = { + params: schema.object({ + pkgkey: schema.string(), + }), + body: schema.object({ + keepPoliciesUpToDate: schema.boolean(), + }), +}; + export const GetStatsRequestSchema = { params: schema.object({ pkgName: schema.string(), diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/packages.ts b/x-pack/plugins/fleet/storybook/context/fixtures/packages.ts index 251024a4e7cdb..2a6012272d4b8 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/packages.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/packages.ts @@ -985,6 +985,7 @@ export const response: GetPackagesResponse['response'] = [ install_status: 'installed', install_started_at: '2021-08-25T19:44:41.090Z', install_source: 'registry', + keep_policies_up_to_date: false, }, references: [], coreMigrationVersion: '7.14.0', @@ -1113,6 +1114,7 @@ export const response: GetPackagesResponse['response'] = [ install_status: 'installed', install_started_at: '2021-08-25T19:44:37.078Z', install_source: 'registry', + keep_policies_up_to_date: false, }, references: [], coreMigrationVersion: '7.14.0', @@ -4268,6 +4270,7 @@ export const response: GetPackagesResponse['response'] = [ install_status: 'installed', install_started_at: '2021-08-25T19:44:43.380Z', install_source: 'registry', + keep_policies_up_to_date: false, }, references: [], coreMigrationVersion: '7.14.0', diff --git a/x-pack/plugins/fleet/storybook/context/index.tsx b/x-pack/plugins/fleet/storybook/context/index.tsx index 6c6b2e479d234..e5a360c28385b 100644 --- a/x-pack/plugins/fleet/storybook/context/index.tsx +++ b/x-pack/plugins/fleet/storybook/context/index.tsx @@ -13,12 +13,12 @@ import { createBrowserHistory } from 'history'; import { I18nProvider } from '@kbn/i18n/react'; import { ScopedHistory } from '../../../../../src/core/public'; +import { getStorybookContextProvider } from '../../../../../src/plugins/custom_integrations/storybook'; import { IntegrationsAppContext } from '../../public/applications/integrations/app'; import type { FleetConfigType, FleetStartServices } from '../../public/plugin'; -// TODO: This is a contract leak, and should be on the context, rather than a setter. +// TODO: These are contract leaks, and should be on the context, rather than a setter. import { setHttpClient } from '../../public/hooks/use_request'; - import { setCustomIntegrations } from '../../public/services/custom_integrations'; import { getApplication } from './application'; @@ -36,7 +36,6 @@ import { stubbedStartServices } from './stubs'; // Expect this to grow as components that are given Stories need access to mocked services. export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ children: storyChildren, - storyContext, }) => { const basepath = ''; const browserHistory = createBrowserHistory(); @@ -56,6 +55,9 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ injectedMetadata: { getInjectedVar: () => null, }, + customIntegrations: { + ContextProvider: getStorybookContextProvider(), + }, ...stubbedStartServices, }; diff --git a/x-pack/plugins/fleet/storybook/decorator.tsx b/x-pack/plugins/fleet/storybook/decorator.tsx index 91d6cc41e6b9a..8e68249809574 100644 --- a/x-pack/plugins/fleet/storybook/decorator.tsx +++ b/x-pack/plugins/fleet/storybook/decorator.tsx @@ -10,6 +10,6 @@ import type { DecoratorFn } from '@storybook/react'; import { StorybookContext } from './context'; -export const decorator: DecoratorFn = (story: Function) => { +export const decorator: DecoratorFn = (story, storybook) => { return <StorybookContext>{story()}</StorybookContext>; }; diff --git a/x-pack/plugins/fleet/storybook/smoke.test.tsx b/x-pack/plugins/fleet/storybook/smoke.test.tsx index e7bdc2e9d82a2..b0a9b1a395553 100644 --- a/x-pack/plugins/fleet/storybook/smoke.test.tsx +++ b/x-pack/plugins/fleet/storybook/smoke.test.tsx @@ -10,13 +10,16 @@ import { createElement } from 'react'; import { act } from 'react-dom/test-utils'; import initStoryshots from '@storybook/addon-storyshots'; -initStoryshots({ - configPath: __dirname, - framework: 'react', - test: async ({ story }) => { - const renderer = mount(createElement(story.render)); - // wait until the element will perform all renders and resolve all promises (lazy loading, especially) - await act(() => new Promise((resolve) => setTimeout(resolve, 0))); - expect(renderer.html()).not.toContain('euiErrorBoundary'); - }, +// skipped: https://github.com/elastic/kibana/issues/113991 +describe.skip('Fleet Storybook Smoke', () => { + initStoryshots({ + configPath: __dirname, + framework: 'react', + test: async ({ story }) => { + const renderer = mount(createElement(story.render)); + // wait until the element will perform all renders and resolve all promises (lazy loading, especially) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + expect(renderer.html()).not.toContain('euiErrorBoundary'); + }, + }); }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index 1c75ef314b728..b24defcdcd79c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -25,6 +25,7 @@ export type TestSubjects = | 'ilmPolicyLink' | 'includeStatsSwitch' | 'includeManagedSwitch' + | 'indexManagementHeaderContent' | 'indexTable' | 'indexTableIncludeHiddenIndicesToggle' | 'indexTableIndexNameLink' diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts index 921812943a3e6..a15e4f2a613d3 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts @@ -24,24 +24,31 @@ const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), te export interface HomeTestBed extends TestBed<TestSubjects> { actions: { selectHomeTab: (tab: 'indicesTab' | 'templatesTab') => void; + toggleHiddenIndices: () => void; }; } export const setup = async (): Promise<HomeTestBed> => { const testBed = await initTestBed(); + const { find } = testBed; /** * User Actions */ const selectHomeTab = (tab: 'indicesTab' | 'templatesTab') => { - testBed.find(tab).simulate('click'); + find(tab).simulate('click'); + }; + + const toggleHiddenIndices = async function () { + find('indexTableIncludeHiddenIndicesToggle').simulate('click'); }; return { ...testBed, actions: { selectHomeTab, + toggleHiddenIndices, }, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts index 42863d36050d7..426bb11f3c733 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts @@ -54,15 +54,18 @@ describe('<IndexManagementHome />', () => { }); describe('tabs', () => { - test('should have 2 tabs', () => { + test('should have 4 tabs', () => { const { find } = testBed; - const templatesTab = find('templatesTab'); - const indicesTab = find('indicesTab'); - expect(indicesTab.length).toBe(1); - expect(indicesTab.text()).toEqual('Indices'); - expect(templatesTab.length).toBe(1); - expect(templatesTab.text()).toEqual('Index Templates'); + const indexManagementContainer = find('indexManagementHeaderContent'); + const tabListContainer = indexManagementContainer.find('.euiTabs'); + const allTabs = tabListContainer.children(); + const allTabsLabels = ['Indices', 'Data Streams', 'Index Templates', 'Component Templates']; + + expect(allTabs.length).toBe(4); + for (let i = 0; i < allTabs.length; i++) { + expect(tabListContainer.childAt(i).text()).toEqual(allTabsLabels[i]); + } }); test('should navigate to Index Templates tab', async () => { diff --git a/x-pack/plugins/index_management/public/application/components/no_match/no_match.tsx b/x-pack/plugins/index_management/public/application/components/no_match/no_match.tsx index 34cbc10a32d09..8546a179fafdf 100644 --- a/x-pack/plugins/index_management/public/application/components/no_match/no_match.tsx +++ b/x-pack/plugins/index_management/public/application/components/no_match/no_match.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; export const NoMatch = () => ( - <div> + <div data-test-subj="noIndicesMessage"> <FormattedMessage id="xpack.idxMgmt.noMatch.noIndicesDescription" defaultMessage="No indices to show" diff --git a/x-pack/plugins/index_management/public/application/sections/home/home.tsx b/x-pack/plugins/index_management/public/application/sections/home/home.tsx index 003aa045f9591..8fce2d354f87b 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/home.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/home.tsx @@ -85,6 +85,7 @@ export const IndexManagementHome: React.FunctionComponent<RouteComponentProps<Ma return ( <> <EuiPageHeader + data-test-subj="indexManagementHeaderContent" pageTitle={ <span data-test-subj="appTitle"> <FormattedMessage id="xpack.idxMgmt.home.appTitle" defaultMessage="Index Management" /> diff --git a/x-pack/plugins/infra/common/infra_ml/infra_ml.ts b/x-pack/plugins/infra/common/infra_ml/infra_ml.ts index 18c9f591395ee..81e1009a80585 100644 --- a/x-pack/plugins/infra/common/infra_ml/infra_ml.ts +++ b/x-pack/plugins/infra/common/infra_ml/infra_ml.ts @@ -12,6 +12,7 @@ export type JobStatus = | 'initializing' | 'stopped' | 'started' + | 'starting' | 'finished' | 'failed'; @@ -35,10 +36,10 @@ export type SetupStatus = * before this state was reached. */ export const isJobStatusWithResults = (jobStatus: JobStatus) => - ['started', 'finished', 'stopped', 'failed'].includes(jobStatus); + ['started', 'starting', 'finished', 'stopped', 'failed'].includes(jobStatus); export const isHealthyJobStatus = (jobStatus: JobStatus) => - ['started', 'finished'].includes(jobStatus); + ['started', 'starting', 'finished'].includes(jobStatus); /** * Maps a setup status to the possibility that results have already been diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis.ts index 18c9f591395ee..81e1009a80585 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_analysis.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_analysis.ts @@ -12,6 +12,7 @@ export type JobStatus = | 'initializing' | 'stopped' | 'started' + | 'starting' | 'finished' | 'failed'; @@ -35,10 +36,10 @@ export type SetupStatus = * before this state was reached. */ export const isJobStatusWithResults = (jobStatus: JobStatus) => - ['started', 'finished', 'stopped', 'failed'].includes(jobStatus); + ['started', 'starting', 'finished', 'stopped', 'failed'].includes(jobStatus); export const isHealthyJobStatus = (jobStatus: JobStatus) => - ['started', 'finished'].includes(jobStatus); + ['started', 'starting', 'finished'].includes(jobStatus); /** * Maps a setup status to the possibility that results have already been 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 d4e1f7366dd2a..2a5f68b3c32e2 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 @@ -41,6 +41,7 @@ export type FetchJobStatusRequestPayload = rt.TypeOf<typeof fetchJobStatusReques const datafeedStateRT = rt.keyof({ started: null, + starting: null, stopped: null, stopping: null, '': null, @@ -89,6 +90,7 @@ export const jobSummaryRT = rt.intersection([ jobState: jobStateRT, }), rt.partial({ + awaitingNodeAssignment: rt.boolean, datafeedIndices: rt.array(rt.string), datafeedState: datafeedStateRT, fullJob: rt.partial({ diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts index 6304471e818fa..7e3c2cc10d7da 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts @@ -117,6 +117,7 @@ const datafeedSetupResponseRT = rt.intersection([ success: rt.boolean, }), rt.partial({ + awaitingNodeAssignment: rt.boolean, error: setupErrorResponseRT, }), ]); diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx index 03522fd9ce6e5..9b0946c87dd10 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx @@ -99,7 +99,7 @@ const createStatusReducer = {} as Record<JobType, JobStatus> ); const nextSetupStatus: SetupStatus = Object.values<JobStatus>(nextJobStatus).every( - (jobState) => jobState === 'started' + (jobState) => jobState === 'started' || jobState === 'starting' ) ? { type: 'succeeded' } : { @@ -224,9 +224,17 @@ const getJobStatus = jobSummary.datafeedState === 'stopped' ) { return 'stopped'; - } else if (jobSummary.jobState === 'opening') { + } else if ( + jobSummary.jobState === 'opening' && + jobSummary.awaitingNodeAssignment === false + ) { return 'initializing'; - } else if (jobSummary.jobState === 'opened' && jobSummary.datafeedState === 'started') { + } else if ( + (jobSummary.jobState === 'opened' && jobSummary.datafeedState === 'started') || + (jobSummary.jobState === 'opening' && + jobSummary.datafeedState === 'starting' && + jobSummary.awaitingNodeAssignment === true) + ) { return 'started'; } diff --git a/x-pack/plugins/infra/public/containers/ml/api/ml_get_jobs_summary_api.ts b/x-pack/plugins/infra/public/containers/ml/api/ml_get_jobs_summary_api.ts index 585aa8786286c..c7dc1d509cf11 100644 --- a/x-pack/plugins/infra/public/containers/ml/api/ml_get_jobs_summary_api.ts +++ b/x-pack/plugins/infra/public/containers/ml/api/ml_get_jobs_summary_api.ts @@ -41,6 +41,7 @@ export type FetchJobStatusRequestPayload = rt.TypeOf<typeof fetchJobStatusReques const datafeedStateRT = rt.keyof({ started: null, + starting: null, stopped: null, stopping: null, '': null, @@ -77,6 +78,7 @@ export const jobSummaryRT = rt.intersection([ jobState: jobStateRT, }), rt.partial({ + awaitingNodeAssignment: rt.boolean, datafeedIndices: rt.array(rt.string), datafeedState: datafeedStateRT, fullJob: rt.partial({ diff --git a/x-pack/plugins/infra/public/containers/ml/infra_ml_module_status.tsx b/x-pack/plugins/infra/public/containers/ml/infra_ml_module_status.tsx index 9bbd7720e168f..bc1f4b00c4cbe 100644 --- a/x-pack/plugins/infra/public/containers/ml/infra_ml_module_status.tsx +++ b/x-pack/plugins/infra/public/containers/ml/infra_ml_module_status.tsx @@ -224,9 +224,17 @@ const getJobStatus = jobSummary.datafeedState === 'stopped' ) { return 'stopped'; - } else if (jobSummary.jobState === 'opening') { + } else if ( + jobSummary.jobState === 'opening' && + jobSummary.awaitingNodeAssignment === false + ) { return 'initializing'; - } else if (jobSummary.jobState === 'opened' && jobSummary.datafeedState === 'started') { + } else if ( + (jobSummary.jobState === 'opened' && jobSummary.datafeedState === 'started') || + (jobSummary.jobState === 'opening' && + jobSummary.datafeedState === 'starting' && + jobSummary.awaitingNodeAssignment === true) + ) { return 'started'; } diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index a8d98ea843c96..542b1dcd21d80 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -25,6 +25,7 @@ import { LogEntryCategoriesResultsContent } from './page_results_content'; import { LogEntryCategoriesSetupContent } from './page_setup_content'; import { LogsPageTemplate } from '../page_template'; import type { LazyObservabilityPageTemplateProps } from '../../../../../observability/public'; +import { useLogSourceContext } from '../../../containers/logs/log_source'; const logCategoriesTitle = i18n.translate('xpack.infra.logs.logCategoriesTitle', { defaultMessage: 'Categories', @@ -114,8 +115,10 @@ const CategoriesPageTemplate: React.FC<LazyObservabilityPageTemplateProps> = ({ children, ...rest }) => { + const { sourceStatus } = useLogSourceContext(); return ( <LogsPageTemplate + hasData={sourceStatus?.logIndexStatus !== 'missing'} data-test-subj="logsLogEntryCategoriesPage" pageHeader={ rest.isEmptyState diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index bf6fe978ddae2..2cb96f4ab0641 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -32,6 +32,8 @@ import { RecreateJobButton } from '../../../components/logging/log_analysis_setu import { AnalyzeInMlButton } from '../../../components/logging/log_analysis_results'; import { useMlHref, ML_PAGES } from '../../../../../ml/public'; import { DatasetsSelector } from '../../../components/logging/log_analysis_results/datasets_selector'; +import { useLogSourceContext } from '../../../containers/logs/log_source'; +import { MLJobsAwaitingNodeWarning } from '../../../../../ml/public'; const JOB_STATUS_POLLING_INTERVAL = 30000; @@ -49,6 +51,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent<LogEntryC services: { ml, http }, } = useKibanaContextForPlugin(); + const { sourceStatus } = useLogSourceContext(); const { hasLogAnalysisSetupCapabilities } = useLogAnalysisCapabilitiesContext(); const { @@ -210,6 +213,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent<LogEntryC endTimestamp={categoryQueryTimeRange.timeRange.endTime} > <LogsPageTemplate + hasData={sourceStatus?.logIndexStatus !== 'missing'} pageHeader={{ pageTitle, rightSideItems: [ @@ -246,6 +250,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent<LogEntryC </EuiFlexGroup> </EuiFlexItem> <EuiFlexItem grow={false}> + <MLJobsAwaitingNodeWarning jobIds={Object.values(jobIds)} /> <CategoryJobNoticesSection hasOutdatedJobConfigurations={hasOutdatedJobConfigurations} hasOutdatedJobDefinitions={hasOutdatedJobDefinitions} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx index 04909b059d8c3..b5d04406cd4c0 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx @@ -20,6 +20,7 @@ import { useLogAnalysisSetupFlyoutStateContext, } from '../../../components/logging/log_analysis_setup/setup_flyout'; import { SubscriptionSplashPage } from '../../../components/subscription_splash_content'; +import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; @@ -155,8 +156,10 @@ const AnomaliesPageTemplate: React.FC<LazyObservabilityPageTemplateProps> = ({ children, ...rest }) => { + const { sourceStatus } = useLogSourceContext(); return ( <LogsPageTemplate + hasData={sourceStatus?.logIndexStatus !== 'missing'} data-test-subj="logsLogEntryRatePage" pageHeader={ rest.isEmptyState diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index 2aac520dbc28a..7f82a8841fdbf 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -33,6 +33,7 @@ import { useLogAnalysisResultsUrlState } from './use_log_entry_rate_results_url_ import { isJobStatusWithResults } from '../../../../common/log_analysis'; import { LogsPageTemplate } from '../page_template'; import { ManageJobsButton } from '../../../components/logging/log_analysis_setup/manage_jobs_button'; +import { MLJobsAwaitingNodeWarning } from '../../../../../ml/public'; export const SORT_DEFAULTS = { direction: 'desc' as const, @@ -51,7 +52,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent<{ const navigateToApp = useKibana().services.application?.navigateToApp; - const { sourceId } = useLogSourceContext(); + const { sourceId, sourceStatus } = useLogSourceContext(); const { hasLogAnalysisSetupCapabilities } = useLogAnalysisCapabilitiesContext(); @@ -195,6 +196,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent<{ return ( <LogsPageTemplate + hasData={sourceStatus?.logIndexStatus !== 'missing'} pageHeader={{ pageTitle, rightSideItems: [<ManageJobsButton onClick={showModuleList} size="s" />], @@ -234,6 +236,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent<{ onRecreateMlJobForReconfiguration={showLogEntryRateSetup} onRecreateMlJobForUpdate={showLogEntryRateSetup} /> + <MLJobsAwaitingNodeWarning jobIds={jobIds} /> <CategoryJobNoticesSection hasOutdatedJobConfigurations={hasOutdatedLogEntryCategoriesJobConfigurations} hasOutdatedJobDefinitions={hasOutdatedLogEntryCategoriesJobDefinitions} diff --git a/x-pack/plugins/infra/public/pages/logs/page_template.tsx b/x-pack/plugins/infra/public/pages/logs/page_template.tsx index 42ca992e9402d..7ee60ab84bf25 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_template.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_template.tsx @@ -6,19 +6,61 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; import type { LazyObservabilityPageTemplateProps } from '../../../../observability/public'; +import { + KibanaPageTemplateProps, + useKibana, +} from '../../../../../../src/plugins/kibana_react/public'; -export const LogsPageTemplate: React.FC<LazyObservabilityPageTemplateProps> = ( - pageTemplateProps -) => { +interface LogsPageTemplateProps extends LazyObservabilityPageTemplateProps { + hasData?: boolean; +} + +export const LogsPageTemplate: React.FC<LogsPageTemplateProps> = ({ + hasData = true, + 'data-test-subj': _dataTestSubj, + ...pageTemplateProps +}) => { const { services: { observability: { navigation: { PageTemplate }, }, + docLinks, }, } = useKibanaContextForPlugin(); - return <PageTemplate {...pageTemplateProps} />; + const { http } = useKibana().services; + const basePath = http!.basePath.get(); + + const noDataConfig: KibanaPageTemplateProps['noDataConfig'] = hasData + ? undefined + : { + solution: i18n.translate('xpack.infra.logs.noDataConfig.solutionName', { + defaultMessage: 'Observability', + }), + actions: { + beats: { + title: i18n.translate('xpack.infra.logs.noDataConfig.beatsCard.title', { + defaultMessage: 'Add logs with Beats', + }), + description: i18n.translate('xpack.infra.logs.noDataConfig.beatsCard.description', { + defaultMessage: + 'Use Beats to send logs to Elasticsearch. We make it easy with modules for many popular systems and apps.', + }), + href: basePath + `/app/home#/tutorial_directory/logging`, + }, + }, + docsLink: docLinks.links.observability.guide, + }; + + return ( + <PageTemplate + data-test-subj={hasData ? _dataTestSubj : 'noDataPage'} + noDataConfig={noDataConfig} + {...pageTemplateProps} + /> + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx index 527dc79726044..7f8ed4fa6a951 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx @@ -11,7 +11,6 @@ import { LogSourceErrorPage } from '../../../components/logging/log_source_error import { SourceLoadingPage } from '../../../components/source_loading_page'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { LogsPageLogsContent } from './page_logs_content'; -import { LogsPageNoIndicesContent } from './page_no_indices_content'; import { LogsPageTemplate } from '../page_template'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { APP_WRAPPER_CLASS } from '../../../../../../../src/core/public'; @@ -34,10 +33,11 @@ export const StreamPageContent: React.FunctionComponent = () => { return <SourceLoadingPage />; } else if (hasFailedLoading) { return <LogSourceErrorPage errors={latestLoadSourceFailures} onRetry={loadSource} />; - } else if (sourceStatus?.logIndexStatus !== 'missing') { + } else { return ( <LogStreamPageWrapper className={APP_WRAPPER_CLASS}> <LogsPageTemplate + hasData={sourceStatus?.logIndexStatus !== 'missing'} pageHeader={{ pageTitle: streamTitle, }} @@ -46,8 +46,6 @@ export const StreamPageContent: React.FunctionComponent = () => { </LogsPageTemplate> </LogStreamPageWrapper> ); - } else { - return <LogsPageNoIndicesContent />; } }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/flyout_home.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/flyout_home.tsx index a16aff903776e..c7443618e458f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/flyout_home.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/flyout_home.tsx @@ -16,6 +16,7 @@ import { EuiButtonEmpty } from '@elastic/eui'; import moment from 'moment'; import { EuiTabs } from '@elastic/eui'; import { EuiTab } from '@elastic/eui'; +import { MLJobsAwaitingNodeWarning } from '../../../../../../../../ml/public'; import { SubscriptionSplashPrompt } from '../../../../../../components/subscription_splash_content'; import { useInfraMLCapabilitiesContext } from '../../../../../../containers/ml/infra_ml_capabilities'; import { @@ -120,14 +121,18 @@ export const FlyoutHome = (props: Props) => { <EuiFlyoutBody banner={ - tab === 'jobs' && - hasJobs && ( - <JobsEnabledCallout - hasHostJobs={hostJobSummaries.length > 0} - hasK8sJobs={k8sJobSummaries.length > 0} - jobIds={jobIds} - /> - ) + <> + {tab === 'jobs' && hasJobs && ( + <> + <JobsEnabledCallout + hasHostJobs={hostJobSummaries.length > 0} + hasK8sJobs={k8sJobSummaries.length > 0} + jobIds={jobIds} + /> + </> + )} + <MLJobsAwaitingNodeWarning jobIds={jobIds} /> + </> } > {tab === 'jobs' && ( diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 353997d5fe3ff..67e39a11c12e7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -5,23 +5,19 @@ * 2.0. */ -import { EuiButton, EuiErrorBoundary, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; import { FilterBar } from './components/filter_bar'; import { DocumentTitle } from '../../../components/document_title'; -import { NoIndices } from '../../../components/empty_states/no_indices'; import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; -import { ViewSourceConfigurationButton } from '../../../components/source_configuration/view_source_configuration_button'; import { Source } from '../../../containers/metrics_source'; import { useTrackPageview } from '../../../../../observability/public'; import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { LayoutView } from './components/layout_view'; -import { useLinkProps } from '../../../hooks/use_link_props'; import { SavedViewProvider } from '../../../containers/saved_view/saved_view'; import { DEFAULT_WAFFLE_VIEW_STATE } from './hooks/use_waffle_view_state'; import { useWaffleOptionsContext } from './hooks/use_waffle_options'; @@ -32,7 +28,6 @@ import { inventoryTitle } from '../../../translations'; import { SavedViews } from './components/saved_views'; export const SnapshotPage = () => { - const uiCapabilities = useKibana().services.application?.capabilities; const { hasFailedLoadingSource, isLoading, @@ -45,11 +40,6 @@ export const SnapshotPage = () => { useTrackPageview({ app: 'infra_metrics', path: 'inventory', delay: 15000 }); const { source: optionsSource } = useWaffleOptionsContext(); - const tutorialLinkProps = useLinkProps({ - app: 'home', - hash: '/tutorial_directory/metrics', - }); - useMetricsBreadcrumbs([ { text: inventoryTitle, @@ -79,6 +69,7 @@ export const SnapshotPage = () => { defaultViewState={DEFAULT_WAFFLE_VIEW_STATE} > <MetricsPageTemplate + hasData={metricIndicesExist} pageHeader={{ pageTitle: inventoryTitle, rightSideItems: [<SavedViews />], @@ -96,43 +87,7 @@ export const SnapshotPage = () => { ) : hasFailedLoadingSource ? ( <SourceErrorPage errorMessage={loadSourceFailureMessage || ''} retry={loadSource} /> ) : ( - <NoIndices - title={i18n.translate('xpack.infra.homePage.noMetricsIndicesTitle', { - defaultMessage: "Looks like you don't have any metrics indices.", - })} - message={i18n.translate('xpack.infra.homePage.noMetricsIndicesDescription', { - defaultMessage: "Let's add some!", - })} - actions={ - <EuiFlexGroup> - <EuiFlexItem> - <EuiButton - {...tutorialLinkProps} - color="primary" - fill - data-test-subj="infrastructureViewSetupInstructionsButton" - > - {i18n.translate('xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel', { - defaultMessage: 'View setup instructions', - })} - </EuiButton> - </EuiFlexItem> - {uiCapabilities?.infrastructure?.configureSource ? ( - <EuiFlexItem> - <ViewSourceConfigurationButton - app="metrics" - data-test-subj="configureSourceButton" - > - {i18n.translate('xpack.infra.configureSourceActionLabel', { - defaultMessage: 'Change source configuration', - })} - </ViewSourceConfigurationButton> - </EuiFlexItem> - ) : null} - </EuiFlexGroup> - } - data-test-subj="noMetricsIndicesPrompt" - /> + <MetricsPageTemplate hasData={metricIndicesExist} data-test-subj="noMetricsIndicesPrompt" /> )} </EuiErrorBoundary> ); diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/node_details_page.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/node_details_page.tsx index 496a57cf7273a..8da96586be979 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/node_details_page.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/node_details_page.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState, useContext } from 'react'; import dateMath from '@elastic/datemath'; import moment from 'moment'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Source } from '../../../../containers/metrics_source'; import { InventoryMetric, InventoryItemType } from '../../../../../common/inventory_models/types'; import { useNodeDetails } from '../hooks/use_node_details'; import { MetricsSideNav } from './side_nav'; @@ -52,6 +53,7 @@ const parseRange = (range: MetricsTimeInput) => { }; export const NodeDetailsPage = (props: Props) => { + const { metricIndicesExist } = useContext(Source.Context); const [parsedTimeRange, setParsedTimeRange] = useState(parseRange(props.timeRange)); const { metrics, loading, makeRequest, error } = useNodeDetails( props.requiredMetrics, @@ -80,6 +82,7 @@ export const NodeDetailsPage = (props: Props) => { return ( <MetricsPageTemplate + hasData={metricIndicesExist} pageHeader={{ pageTitle: props.name, rightSideItems: [ diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx index 2f2ebaf895c37..b8a52a8f34400 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx @@ -38,7 +38,7 @@ export const MetricDetail = withMetricPageProviders( const nodeId = match.params.node; const nodeType = match.params.type as InventoryItemType; const inventoryModel = findInventoryModel(nodeType); - const { sourceId } = useContext(Source.Context); + const { sourceId, metricIndicesExist } = useContext(Source.Context); const { timeRange, @@ -86,7 +86,7 @@ export const MetricDetail = withMetricPageProviders( if (metadataLoading && !filteredRequiredMetrics.length) { return ( - <MetricsPageTemplate> + <MetricsPageTemplate hasData={metricIndicesExist}> <InfraLoadingPanel height="100vh" width="100%" diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx index 2e2232b09d7f1..f2345b0b8e020 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx @@ -7,17 +7,17 @@ import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useEffect } from 'react'; +import React, { useEffect, useContext } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; import { MetricsSourceConfigurationProperties } from '../../../../common/metrics_sources'; import { useTrackPageview } from '../../../../../observability/public'; import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; - import { DocumentTitle } from '../../../components/document_title'; import { NoData } from '../../../components/empty_states'; import { MetricsExplorerCharts } from './components/charts'; import { MetricsExplorerToolbar } from './components/toolbar'; import { useMetricsExplorerState } from './hooks/use_metric_explorer_state'; +import { Source } from '../../../containers/metrics_source'; import { useSavedViewContext } from '../../../containers/saved_view/saved_view'; import { MetricsPageTemplate } from '../page_template'; import { metricsExplorerTitle } from '../../../translations'; @@ -52,6 +52,7 @@ export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExpl useTrackPageview({ app: 'infra_metrics', path: 'metrics_explorer' }); useTrackPageview({ app: 'infra_metrics', path: 'metrics_explorer', delay: 15000 }); + const { metricIndicesExist } = useContext(Source.Context); useEffect(() => { if (currentView) { onViewStateChange(currentView); @@ -85,6 +86,7 @@ export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExpl } /> <MetricsPageTemplate + hasData={metricIndicesExist} pageHeader={{ pageTitle: metricsExplorerTitle, rightSideItems: [ diff --git a/x-pack/plugins/infra/public/pages/metrics/page_template.tsx b/x-pack/plugins/infra/public/pages/metrics/page_template.tsx index c32133525e8e8..41ea12c280841 100644 --- a/x-pack/plugins/infra/public/pages/metrics/page_template.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/page_template.tsx @@ -6,19 +6,61 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; import type { LazyObservabilityPageTemplateProps } from '../../../../observability/public'; +import { KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public'; +import { useLinkProps } from '../../hooks/use_link_props'; -export const MetricsPageTemplate: React.FC<LazyObservabilityPageTemplateProps> = ( - pageTemplateProps -) => { +interface MetricsPageTemplateProps extends LazyObservabilityPageTemplateProps { + hasData?: boolean; +} + +export const MetricsPageTemplate: React.FC<MetricsPageTemplateProps> = ({ + hasData = true, + 'data-test-subj': _dataTestSubj, + ...pageTemplateProps +}) => { const { services: { observability: { navigation: { PageTemplate }, }, + docLinks, }, } = useKibanaContextForPlugin(); - return <PageTemplate {...pageTemplateProps} />; + const tutorialLinkProps = useLinkProps({ + app: 'home', + hash: '/tutorial_directory/metrics', + }); + + const noDataConfig: KibanaPageTemplateProps['noDataConfig'] = hasData + ? undefined + : { + solution: i18n.translate('xpack.infra.metrics.noDataConfig.solutionName', { + defaultMessage: 'Observability', + }), + actions: { + beats: { + title: i18n.translate('xpack.infra.metrics.noDataConfig.beatsCard.title', { + defaultMessage: 'Add metrics with Beats', + }), + description: i18n.translate('xpack.infra.metrics.noDataConfig.beatsCard.description', { + defaultMessage: + 'Use Beats to send metrics data to Elasticsearch. We make it easy with modules for many popular systems and apps.', + }), + ...tutorialLinkProps, + }, + }, + docsLink: docLinks.links.observability.guide, + }; + + return ( + <PageTemplate + data-test-subj={hasData ? _dataTestSubj : 'noDataPage'} + noDataConfig={noDataConfig} + {...pageTemplateProps} + /> + ); }; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index bbbb0e917d518..72d9ea9e39def 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -7,7 +7,7 @@ import { first, get, last } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { ALERT_REASON } from '@kbn/rule-data-utils'; +import { ALERT_REASON, ALERT_RULE_PARAMS } from '@kbn/rule-data-utils'; import moment from 'moment'; import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; @@ -79,6 +79,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = id, fields: { [ALERT_REASON]: reason, + [ALERT_RULE_PARAMS]: JSON.stringify(params), }, }); diff --git a/x-pack/plugins/ingest_pipelines/README.md b/x-pack/plugins/ingest_pipelines/README.md index 00d4f5a91863d..dd7c130c7a72d 100644 --- a/x-pack/plugins/ingest_pipelines/README.md +++ b/x-pack/plugins/ingest_pipelines/README.md @@ -1,9 +1,9 @@ -# Ingest Node Pipelines UI +# Ingest Pipelines UI ## Summary -The `ingest_pipelines` plugin provides Kibana support for [Elasticsearch's ingest nodes](https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest.html). Please refer to the Elasticsearch documentation for more details. +The `ingest_pipelines` plugin provides Kibana support for [Elasticsearch's ingest pipelines](https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest.html). -This plugin allows Kibana to create, edit, clone and delete ingest node pipelines. It also provides support to simulate a pipeline. +This plugin allows Kibana to create, edit, clone and delete ingest pipelines. It also provides support to simulate a pipeline. It requires a Basic license and the following cluster privileges: `manage_pipeline` and `cluster:monitor/nodes/info`. @@ -11,7 +11,7 @@ It requires a Basic license and the following cluster privileges: `manage_pipeli ## Development -A new app called Ingest Node Pipelines is registered in the Management section and follows a typical CRUD UI pattern. The client-side portion of this app lives in [public/application](public/application) and uses endpoints registered in [server/routes/api](server/routes/api). For more information on the pipeline processors editor component, check out the [component readme](public/application/components/pipeline_processors_editor/README.md). +A new app called Ingest Pipelines is registered in the Management section and follows a typical CRUD UI pattern. The client-side portion of this app lives in [public/application](public/application) and uses endpoints registered in [server/routes/api](server/routes/api). For more information on the pipeline processors editor component, check out the [component readme](public/application/components/pipeline_processors_editor/README.md). See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions on setting up your development environment. @@ -25,7 +25,7 @@ The app has the following test coverage: ### Quick steps for manual testing -You can run the following request in Console to create an ingest node pipeline: +You can run the following request in Console to create an ingest pipeline: ``` PUT _ingest/pipeline/test_pipeline @@ -73,7 +73,7 @@ PUT _ingest/pipeline/test_pipeline } ``` -Then, go to the Ingest Node Pipelines UI to edit, delete, clone, or view details of the pipeline. +Then, go to the Ingest Pipelines UI to edit, delete, clone, or view details of the pipeline. To simulate a pipeline, go to the "Edit" page of your pipeline. Click the "Add documents" link under the "Processors" section. You may add the following sample documents to test the pipeline: diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts index 78e3f2dab0d1d..19a2abb5a5a52 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts @@ -52,11 +52,11 @@ describe('<PipelinesList />', () => { // Verify app title expect(exists('appTitle')).toBe(true); - expect(find('appTitle').text()).toEqual('Ingest Node Pipelines'); + expect(find('appTitle').text()).toEqual('Ingest Pipelines'); // Verify documentation link expect(exists('documentationLink')).toBe(true); - expect(find('documentationLink').text()).toBe('Ingest Node Pipelines docs'); + expect(find('documentationLink').text()).toBe('Ingest Pipelines docs'); // Verify create button exists expect(exists('createPipelineButton')).toBe(true); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index 0d61d839156e2..eae48a6b46dd1 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -557,7 +557,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { defaultMessage: 'Pipeline', }), typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.pipeline', { - defaultMessage: 'Runs another ingest node pipeline.', + defaultMessage: 'Runs another ingest pipeline.', }), getDefaultDescription: ({ name }) => i18n.translate('xpack.ingestPipelines.processors.defaultDescription.pipeline', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx index ae68cfcb399f0..95621601011f9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -153,7 +153,7 @@ export const PipelinesList: React.FunctionComponent<RouteComponentProps> = ({ <span data-test-subj="appTitle"> <FormattedMessage id="xpack.ingestPipelines.list.listTitle" - defaultMessage="Ingest Node Pipelines" + defaultMessage="Ingest Pipelines" /> </span> } @@ -172,7 +172,7 @@ export const PipelinesList: React.FunctionComponent<RouteComponentProps> = ({ > <FormattedMessage id="xpack.ingestPipelines.list.pipelinesDocsLinkText" - defaultMessage="Ingest Node Pipelines docs" + defaultMessage="Ingest Pipelines docs" /> </EuiButtonEmpty>, ]} diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts b/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts index 138fdf4e8ead6..f1b2d22e776b9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts @@ -11,7 +11,7 @@ import { ManagementAppMountParams } from '../../../../../../src/plugins/manageme type SetBreadcrumbs = ManagementAppMountParams['setBreadcrumbs']; const homeBreadcrumbText = i18n.translate('xpack.ingestPipelines.breadcrumb.pipelinesLabel', { - defaultMessage: 'Ingest Node Pipelines', + defaultMessage: 'Ingest Pipelines', }); export class BreadcrumbService { diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts index 4e85490de6209..68dc2c1801e0c 100644 --- a/x-pack/plugins/ingest_pipelines/public/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -25,7 +25,7 @@ export class IngestPipelinesPlugin apiService.setup(http, uiMetricService); const pluginName = i18n.translate('xpack.ingestPipelines.appTitle', { - defaultMessage: 'Ingest Node Pipelines', + defaultMessage: 'Ingest Pipelines', }); management.sections.section.ingest.registerApp({ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 7f1e4aa58dba3..f3245759c9ef5 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -115,6 +115,7 @@ export function getSuggestions({ } else { dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState( datasourceState, + (layerId) => isLayerSupportedByVisualization(layerId, [layerTypes.DATA]), activeData ); } diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 263198871f07a..2e0ab2401c70f 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -7,6 +7,7 @@ import { isEqual, uniqBy } from 'lodash'; import React from 'react'; +import { i18n } from '@kbn/i18n'; import { render, unmountComponentAtNode } from 'react-dom'; import type { ExecutionContextSearch, @@ -41,11 +42,7 @@ import { ReferenceOrValueEmbeddable, } from '../../../../../src/plugins/embeddable/public'; import { Document, injectFilterReferences } from '../persistence'; -import { - ExpressionWrapper, - ExpressionWrapperProps, - savedObjectConflictError, -} from './expression_wrapper'; +import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { isLensBrushEvent, @@ -63,6 +60,7 @@ import { LensAttributeService } from '../lens_attribute_service'; import type { ErrorMessage } from '../editor_frame_service/types'; import { getLensInspectorService, LensInspector } from '../lens_inspector_service'; import { SharingSavedObjectProps } from '../types'; +import type { SpacesPluginStart } from '../../../spaces/public'; export type LensSavedObjectAttributes = Omit<Document, 'savedObjectId' | 'type'>; export interface ResolvedLensSavedObjectAttributes extends LensSavedObjectAttributes { @@ -108,6 +106,7 @@ export interface LensEmbeddableDeps { getTriggerCompatibleActions?: UiActionsStart['getTriggerCompatibleActions']; capabilities: { canSaveVisualizations: boolean; canSaveDashboards: boolean }; usageCollection?: UsageCollectionSetup; + spaces?: SpacesPluginStart; } export class Embeddable @@ -281,8 +280,17 @@ export class Embeddable }; const { ast, errors } = await this.deps.documentToExpression(this.savedVis); this.errors = errors; - if (sharingSavedObjectProps?.outcome === 'conflict') { - const conflictError = savedObjectConflictError(sharingSavedObjectProps.errorJSON!); + if (sharingSavedObjectProps?.outcome === 'conflict' && this.deps.spaces) { + const conflictError = { + shortMessage: i18n.translate('xpack.lens.embeddable.legacyURLConflict.shortMessage', { + defaultMessage: `You've encountered a URL conflict`, + }), + longMessage: ( + <this.deps.spaces.ui.components.getSavedObjectConflictMessage + json={sharingSavedObjectProps.errorJSON!} + /> + ), + }; this.errors = this.errors ? [...this.errors, conflictError] : [conflictError]; } this.expression = ast ? toExpression(ast) : null; diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts index 954905c51a4b7..e51ec4c3e5588 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts @@ -24,6 +24,7 @@ import { LensAttributeService } from '../lens_attribute_service'; import { DOC_TYPE } from '../../common/constants'; import { ErrorMessage } from '../editor_frame_service/types'; import { extract, inject } from '../../common/embeddable_factory'; +import type { SpacesPluginStart } from '../../../spaces/public'; export interface LensEmbeddableStartServices { timefilter: TimefilterContract; @@ -38,6 +39,7 @@ export interface LensEmbeddableStartServices { documentToExpression: ( doc: Document ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; + spaces?: SpacesPluginStart; } export class EmbeddableFactory implements EmbeddableFactoryDefinition { @@ -90,6 +92,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { capabilities, usageCollection, inspector, + spaces, } = await this.getStartServices(); const { Embeddable } = await import('../async_services'); @@ -110,6 +113,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { canSaveVisualizations: Boolean(capabilities.visualize.save), }, usageCollection, + spaces, }, input, parent diff --git a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx index c827fe74cc52b..3de914d13d69d 100644 --- a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx @@ -5,20 +5,10 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiIcon, - EuiEmptyPrompt, - EuiButtonEmpty, - EuiCallOut, - EuiSpacer, - EuiLink, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon, EuiEmptyPrompt } from '@elastic/eui'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -28,7 +18,6 @@ import type { KibanaExecutionContext } from 'src/core/public'; import { ExecutionContextSearch } from 'src/plugins/data/public'; import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions'; import classNames from 'classnames'; -import { i18n } from '@kbn/i18n'; import { getOriginalRequestErrorMessages } from '../editor_frame_service/error_helper'; import { ErrorMessage } from '../editor_frame_service/types'; import { LensInspector } from '../lens_inspector_service'; @@ -172,52 +161,3 @@ export function ExpressionWrapper({ </I18nProvider> ); } - -const SavedObjectConflictMessage = ({ json }: { json: string }) => { - const [expandError, setExpandError] = useState(false); - return ( - <> - <FormattedMessage - id="xpack.lens.embeddable.legacyURLConflict.longMessage" - defaultMessage="Disable the {documentationLink} associated with this object." - values={{ - documentationLink: ( - <EuiLink - external - href="https://www.elastic.co/guide/en/kibana/master/legacy-url-aliases.html" - target="_blank" - > - {i18n.translate('xpack.lens.embeddable.legacyURLConflict.documentationLinkText', { - defaultMessage: 'legacy URL alias', - })} - </EuiLink> - ), - }} - /> - <EuiSpacer /> - {expandError ? ( - <EuiCallOut - title={i18n.translate('xpack.lens.embeddable.legacyURLConflict.expandErrorText', { - defaultMessage: `This object has the same URL as a legacy alias. Disable the alias to resolve this error : {json}`, - values: { json }, - })} - color="danger" - iconType="alert" - /> - ) : ( - <EuiButtonEmpty onClick={() => setExpandError(true)}> - {i18n.translate('xpack.lens.embeddable.legacyURLConflict.expandError', { - defaultMessage: `Show more`, - })} - </EuiButtonEmpty> - )} - </> - ); -}; - -export const savedObjectConflictError = (json: string): ErrorMessage => ({ - shortMessage: i18n.translate('xpack.lens.embeddable.legacyURLConflict.shortMessage', { - defaultMessage: `You've encountered a URL conflict`, - }), - longMessage: <SavedObjectConflictMessage json={json} />, -}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index a5d6db4be3319..bf4b10de386a1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1704,6 +1704,103 @@ describe('IndexPattern Data Source suggestions', () => { ); }); + it('adds date histogram over default time field for tables without time dimension and a threshold', async () => { + const initialState = testInitialState(); + const state: IndexPatternPrivateState = { + ...initialState, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['cola', 'colb'], + columns: { + cola: { + label: 'My Terms', + customLabel: true, + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'source', + scale: 'ordinal', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }, + colb: { + label: 'My Op', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + scale: 'ratio', + }, + }, + }, + threshold: { + indexPatternId: '2', + columnOrder: ['thresholda'], + columns: { + thresholda: { + label: 'My Op', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + scale: 'ratio', + }, + }, + }, + }, + }; + + expect( + getSuggestionSubset( + getDatasourceSuggestionsFromCurrentState(state, (layerId) => layerId !== 'threshold') + ) + ).toContainEqual( + expect.objectContaining({ + table: { + isMultiRow: true, + changeType: 'extended', + label: 'Over time', + columns: [ + { + columnId: 'cola', + operation: { + label: 'My Terms', + dataType: 'string', + isBucketed: true, + scale: 'ordinal', + }, + }, + { + columnId: 'id1', + operation: { + label: 'timestampLabel', + dataType: 'date', + isBucketed: true, + scale: 'interval', + }, + }, + { + columnId: 'colb', + operation: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }, + }, + ], + layerId: 'first', + }, + }) + ); + }); + it('does not create an over time suggestion if tables with numeric buckets with time dimension', async () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 0fe0ef617dc27..604b63aa29246 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -350,9 +350,11 @@ function createNewLayerWithMetricAggregation( } export function getDatasourceSuggestionsFromCurrentState( - state: IndexPatternPrivateState + state: IndexPatternPrivateState, + filterLayers: (layerId: string) => boolean = () => true ): Array<DatasourceSuggestion<IndexPatternPrivateState>> { - const layers = Object.entries(state.layers || {}); + const layers = Object.entries(state.layers || {}).filter(([layerId]) => filterLayers(layerId)); + if (layers.length > 1) { // Return suggestions that reduce the data to each layer individually return layers @@ -394,7 +396,7 @@ export function getDatasourceSuggestionsFromCurrentState( } return flatten( - Object.entries(state.layers || {}) + layers .filter(([_id, layer]) => layer.columnOrder.length && layer.indexPatternId) .map(([layerId, layer]) => { const indexPattern = state.indexPatterns[layer.indexPatternId]; diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 7891b5990989c..1532b2b099104 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -212,6 +212,7 @@ export class LensPlugin { uiActions: plugins.uiActions, usageCollection, inspector: plugins.inspector, + spaces: plugins.spaces, }; }; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 75ed5f4907e0b..2e7876f83fc41 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -246,6 +246,7 @@ export interface Datasource<T = unknown, P = unknown> { ) => Array<DatasourceSuggestion<T>>; getDatasourceSuggestionsFromCurrentState: ( state: T, + filterFn?: (layerId: string) => boolean, activeData?: Record<string, Datatable> ) => Array<DatasourceSuggestion<T>>; diff --git a/x-pack/plugins/licensing/README.md b/x-pack/plugins/licensing/README.md index 3de1fe9cae425..52204c66dd2bf 100644 --- a/x-pack/plugins/licensing/README.md +++ b/x-pack/plugins/licensing/README.md @@ -126,6 +126,7 @@ This change makes NP & LP licensing service not compatible. We have to keep both **LP**: `xpack.xpack_main.xpack_api_polling_frequency_millis` **NP**: `xpack.licensing.api_polling_frequency` +Support for deprecated `xpack.xpack_main.xpack_api_polling_frequency_millis` is removed in v8.0.0. See https://github.com/elastic/kibana/issues/103915 for more details. #### License **NP**: `mode` field is provided, but deprecated. diff --git a/x-pack/plugins/licensing/server/licensing_config.ts b/x-pack/plugins/licensing/server/licensing_config.ts index a27eaba56df50..85de9f84a703f 100644 --- a/x-pack/plugins/licensing/server/licensing_config.ts +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -18,10 +18,4 @@ export const config: PluginConfigDescriptor<LicenseConfigType> = { schema: schema.object({ api_polling_frequency: schema.duration({ defaultValue: '30s' }), }), - deprecations: ({ renameFromRoot }) => [ - renameFromRoot( - 'xpack.xpack_main.xpack_api_polling_frequency_millis', - 'xpack.licensing.api_polling_frequency' - ), - ], }; diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index bfd501dbcb295..2aa2e4a756490 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -29,6 +29,7 @@ "savedObjectsTagging", "charts", "security", + "spaces", "usageCollection" ], "ui": true, diff --git a/x-pack/plugins/maps/public/embeddable/_index.scss b/x-pack/plugins/maps/public/embeddable/_index.scss index 966236f54d259..07c874d966fec 100644 --- a/x-pack/plugins/maps/public/embeddable/_index.scss +++ b/x-pack/plugins/maps/public/embeddable/_index.scss @@ -5,4 +5,12 @@ flex: 1 1 100%; z-index: 1; min-height: 0; // Absolute must for Firefox to scroll contents +} + +.mapEmbeddedError { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; } \ No newline at end of file diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index c15138f6c5b15..b0daace7afa9e 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -12,6 +12,7 @@ import { Provider } from 'react-redux'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subscription } from 'rxjs'; import { Unsubscribe } from 'redux'; +import { EuiEmptyPrompt } from '@elastic/eui'; import { Embeddable, IContainer, @@ -66,6 +67,7 @@ import { getCoreI18n, getHttp, getChartsPaletteServiceGetColor, + getSpacesApi, getSearchService, } from '../kibana_services'; import { LayerDescriptor, MapExtent } from '../../common/descriptor_types'; @@ -353,23 +355,38 @@ export class MapEmbeddable return; } - const I18nContext = getCoreI18n().Context; + const sharingSavedObjectProps = this._savedMap.getSharingSavedObjectProps(); + const spaces = getSpacesApi(); + const content = + sharingSavedObjectProps && spaces && sharingSavedObjectProps?.outcome === 'conflict' ? ( + <div className="mapEmbeddedError"> + <EuiEmptyPrompt + iconType="alert" + iconColor="danger" + data-test-subj="embeddable-maps-failure" + body={spaces.ui.components.getSavedObjectConflictMessage({ + json: sharingSavedObjectProps.errorJSON!, + })} + /> + </div> + ) : ( + <MapContainer + onSingleValueTrigger={this.onSingleValueTrigger} + addFilters={this.input.hideFilterActions ? null : this.addFilters} + getFilterActions={this.getFilterActions} + getActionContext={this.getActionContext} + renderTooltipContent={this._renderTooltipContent} + title={this.getTitle()} + description={this.getDescription()} + waitUntilTimeLayersLoad$={waitUntilTimeLayersLoad$(this._savedMap.getStore())} + isSharable={this._isSharable} + /> + ); + const I18nContext = getCoreI18n().Context; render( <Provider store={this._savedMap.getStore()}> - <I18nContext> - <MapContainer - onSingleValueTrigger={this.onSingleValueTrigger} - addFilters={this.input.hideFilterActions ? null : this.addFilters} - getFilterActions={this.getFilterActions} - getActionContext={this.getActionContext} - renderTooltipContent={this._renderTooltipContent} - title={this.getTitle()} - description={this.getDescription()} - waitUntilTimeLayersLoad$={waitUntilTimeLayersLoad$(this._savedMap.getStore())} - isSharable={this._isSharable} - /> - </I18nContext> + <I18nContext>{content}</I18nContext> </Provider>, this._domNode ); diff --git a/x-pack/plugins/maps/public/inspector/adapters/map_adapter.js b/x-pack/plugins/maps/public/inspector/map_adapter.ts similarity index 78% rename from x-pack/plugins/maps/public/inspector/adapters/map_adapter.js rename to x-pack/plugins/maps/public/inspector/map_adapter.ts index 44561ff6d66ff..3097f686382ac 100644 --- a/x-pack/plugins/maps/public/inspector/adapters/map_adapter.js +++ b/x-pack/plugins/maps/public/inspector/map_adapter.ts @@ -6,9 +6,13 @@ */ import { EventEmitter } from 'events'; +import { Stats } from './types'; class MapAdapter extends EventEmitter { - setMapState({ stats, style }) { + private stats?: Stats; + private style?: string; + + setMapState({ stats, style }: { stats: Stats; style: string }) { this.stats = stats; this.style = style; this._onChange(); diff --git a/x-pack/plugins/maps/public/inspector/views/map_details.js b/x-pack/plugins/maps/public/inspector/map_details.tsx similarity index 74% rename from x-pack/plugins/maps/public/inspector/views/map_details.js rename to x-pack/plugins/maps/public/inspector/map_details.tsx index d477e55270eb5..6689cb0d6ccb4 100644 --- a/x-pack/plugins/maps/public/inspector/views/map_details.js +++ b/x-pack/plugins/maps/public/inspector/map_details.tsx @@ -6,7 +6,6 @@ */ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import { EuiTab, EuiTabs, @@ -22,31 +21,42 @@ import { FormattedMessage } from '@kbn/i18n/react'; const DETAILS_TAB_ID = 'details'; const STYLE_TAB_ID = 'mapStyle'; -class MapDetails extends Component { - tabs = [ - { - id: DETAILS_TAB_ID, - name: i18n.translate('xpack.maps.inspector.mapDetailsTitle', { - defaultMessage: 'Map details', - }), - dataTestSubj: 'mapDetailsTab', - }, - { - id: STYLE_TAB_ID, - name: i18n.translate('xpack.maps.inspector.mapboxStyleTitle', { - defaultMessage: 'Mapbox style', - }), - dataTestSubj: 'mapboxStyleTab', - }, - ]; +const TABS = [ + { + id: DETAILS_TAB_ID, + name: i18n.translate('xpack.maps.inspector.mapDetailsTitle', { + defaultMessage: 'Map details', + }), + dataTestSubj: 'mapDetailsTab', + }, + { + id: STYLE_TAB_ID, + name: i18n.translate('xpack.maps.inspector.mapboxStyleTitle', { + defaultMessage: 'Mapbox style', + }), + dataTestSubj: 'mapboxStyleTab', + }, +]; - state = { +interface Props { + centerLon: number; + centerLat: number; + zoom: number; + style: string; +} + +interface State { + selectedTabId: typeof DETAILS_TAB_ID | typeof STYLE_TAB_ID; +} + +export class MapDetails extends Component<Props, State> { + state: State = { selectedTabId: DETAILS_TAB_ID, }; - onSelectedTabChanged = (id) => { + onSelectedTabChanged = (id: string) => { this.setState({ - selectedTabId: id, + selectedTabId: id as typeof DETAILS_TAB_ID | typeof STYLE_TAB_ID, }); }; @@ -55,7 +65,7 @@ class MapDetails extends Component { return ( <div data-test-subj="mapboxStyleContainer"> <EuiCodeBlock language="json" paddingSize="s"> - {JSON.stringify(this.props.mapStyle, null, 2)} + {JSON.stringify(this.props.style, null, 2)} </EuiCodeBlock> </div> ); @@ -96,7 +106,7 @@ class MapDetails extends Component { }; renderTabs() { - return this.tabs.map((tab, index) => ( + return TABS.map((tab, index) => ( <EuiTab onClick={() => this.onSelectedTabChanged(tab.id)} isSelected={tab.id === this.state.selectedTabId} @@ -118,12 +128,3 @@ class MapDetails extends Component { ); } } - -MapDetails.propTypes = { - centerLon: PropTypes.number.isRequired, - centerLat: PropTypes.number.isRequired, - zoom: PropTypes.number.isRequired, - mapStyle: PropTypes.object.isRequired, -}; - -export { MapDetails }; diff --git a/x-pack/plugins/maps/public/inspector/map_inspector_view.tsx b/x-pack/plugins/maps/public/inspector/map_inspector_view.tsx new file mode 100644 index 0000000000000..77809b81b9077 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/map_inspector_view.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy } from 'react'; +import type { Adapters } from 'src/plugins/inspector/public'; +import { i18n } from '@kbn/i18n'; +import { LazyWrapper } from '../lazy_wrapper'; + +const getLazyComponent = () => { + return lazy(() => import('./map_view_component')); +}; + +export const MapInspectorView = { + title: i18n.translate('xpack.maps.inspector.mapDetailsViewTitle', { + defaultMessage: 'Map details', + }), + order: 30, + help: i18n.translate('xpack.maps.inspector.mapDetailsViewHelpText', { + defaultMessage: 'View the map state', + }), + shouldShow(adapters: Adapters) { + return Boolean(adapters.map); + }, + component: (props: { adapters: Adapters }) => { + return <LazyWrapper getLazyComponent={getLazyComponent} lazyComponentProps={props} />; + }, +}; diff --git a/x-pack/plugins/maps/public/inspector/map_view_component.tsx b/x-pack/plugins/maps/public/inspector/map_view_component.tsx new file mode 100644 index 0000000000000..364f4d4d2d435 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/map_view_component.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component } from 'react'; +import type { Adapters } from 'src/plugins/inspector/public'; +import { MapDetails } from './map_details'; +import { Stats } from './types'; + +interface Props { + adapters: Adapters; +} + +interface State { + stats: Stats; + style: string; +} + +class MapViewComponent extends Component<Props, State> { + state: State = this.props.adapters.map.getMapState(); + + _onMapChange = () => { + this.setState(this.props.adapters.map.getMapState()); + }; + + componentDidMount() { + this.props.adapters.map.on('change', this._onMapChange); + } + + componentWillUnmount() { + this.props.adapters.map.removeListener('change', this._onMapChange); + } + + render() { + return ( + <MapDetails + centerLon={this.state.stats.center[0]} + centerLat={this.state.stats.center[1]} + zoom={this.state.stats.zoom} + style={this.state.style} + /> + ); + } +} + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export default MapViewComponent; diff --git a/x-pack/plugins/maps/public/inspector/types.ts b/x-pack/plugins/maps/public/inspector/types.ts new file mode 100644 index 0000000000000..e8bbd126cdd08 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface Stats { + center: [number, number]; + zoom: number; +} diff --git a/x-pack/plugins/maps/public/inspector/views/map_view.js b/x-pack/plugins/maps/public/inspector/views/map_view.js deleted file mode 100644 index e2aac26dc7d17..0000000000000 --- a/x-pack/plugins/maps/public/inspector/views/map_view.js +++ /dev/null @@ -1,67 +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, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { MapDetails } from './map_details'; -import { i18n } from '@kbn/i18n'; - -class MapViewComponent extends Component { - constructor(props) { - super(props); - props.adapters.map.on('change', this._onMapChange); - - const { stats, style } = props.adapters.map.getMapState(); - this.state = { - stats, - mapStyle: style, - }; - } - - _onMapChange = () => { - const { stats, style } = this.props.adapters.map.getMapState(); - this.setState({ - stats, - mapStyle: style, - }); - }; - - componentWillUnmount() { - this.props.adapters.map.removeListener('change', this._onMapChange); - } - - render() { - return ( - <MapDetails - centerLon={this.state.stats.center[0]} - centerLat={this.state.stats.center[1]} - zoom={this.state.stats.zoom} - mapStyle={this.state.mapStyle} - /> - ); - } -} - -MapViewComponent.propTypes = { - adapters: PropTypes.object.isRequired, -}; - -const MapView = { - title: i18n.translate('xpack.maps.inspector.mapDetailsViewTitle', { - defaultMessage: 'Map details', - }), - order: 30, - help: i18n.translate('xpack.maps.inspector.mapDetailsViewHelpText', { - defaultMessage: 'View the map state', - }), - shouldShow(adapters) { - return Boolean(adapters.map); - }, - component: MapViewComponent, -}; - -export { MapView }; diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 300fe07a841e9..5ad3a1d3fd23d 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -53,6 +53,7 @@ export const getNavigateToApp = () => coreStart.application.navigateToApp; export const getSavedObjectsTagging = () => pluginsStart.savedObjectsTagging; export const getPresentationUtilContext = () => pluginsStart.presentationUtil.ContextProvider; export const getSecurityService = () => pluginsStart.security; +export const getSpacesApi = () => pluginsStart.spaces; // xpack.maps.* kibana.yml settings from this plugin let mapAppConfig: MapsConfigType; diff --git a/x-pack/plugins/maps/public/lazy_wrapper.tsx b/x-pack/plugins/maps/public/lazy_wrapper.tsx new file mode 100644 index 0000000000000..1a808799bf4c4 --- /dev/null +++ b/x-pack/plugins/maps/public/lazy_wrapper.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, Suspense } from 'react'; +import { EuiDelayRender, EuiErrorBoundary, EuiLoadingContent } from '@elastic/eui'; + +const Fallback = () => ( + <EuiDelayRender> + <EuiLoadingContent lines={3} /> + </EuiDelayRender> +); + +interface Props<T> { + getLazyComponent: () => FC<T>; + lazyComponentProps: T; +} + +export function LazyWrapper<T>({ getLazyComponent, lazyComponentProps }: Props<T>) { + const LazyComponent = getLazyComponent(); + return ( + <EuiErrorBoundary> + <Suspense fallback={<Fallback />}> + <LazyComponent {...lazyComponentProps} /> + </Suspense> + </EuiErrorBoundary> + ); +} diff --git a/x-pack/plugins/maps/public/map_attribute_service.ts b/x-pack/plugins/maps/public/map_attribute_service.ts index 5f7c45b1b42d7..ab380ca5a6b66 100644 --- a/x-pack/plugins/maps/public/map_attribute_service.ts +++ b/x-pack/plugins/maps/public/map_attribute_service.ts @@ -14,8 +14,18 @@ import { checkForDuplicateTitle, OnSaveProps } from '../../../../src/plugins/sav import { getCoreOverlays, getEmbeddableService, getSavedObjectsClient } from './kibana_services'; import { extractReferences, injectReferences } from '../common/migrations/references'; import { MapByValueInput, MapByReferenceInput } from './embeddable/types'; +import { getSpacesApi } from './kibana_services'; -type MapDoc = MapSavedObjectAttributes & { references?: SavedObjectReference[] }; +export interface SharingSavedObjectProps { + outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; + aliasTargetId?: string; + errorJSON?: string; +} + +type MapDoc = MapSavedObjectAttributes & { + sharingSavedObjectProps?: SharingSavedObjectProps; + references?: SavedObjectReference[]; +}; export type MapAttributeService = AttributeService<MapDoc, MapByValueInput, MapByReferenceInput>; @@ -58,7 +68,11 @@ export function getMapAttributeService(): MapAttributeService { return { id: savedObject.id }; }, unwrapMethod: async (savedObjectId: string): Promise<MapDoc> => { - const savedObject = await getSavedObjectsClient().get<MapSavedObjectAttributes>( + const { + saved_object: savedObject, + outcome, + alias_target_id: aliasTargetId, + } = await getSavedObjectsClient().resolve<MapSavedObjectAttributes>( MAP_SAVED_OBJECT_TYPE, savedObjectId ); @@ -68,7 +82,22 @@ export function getMapAttributeService(): MapAttributeService { } const { attributes } = injectReferences(savedObject); - return { ...attributes, references: savedObject.references }; + return { + ...attributes, + references: savedObject.references, + sharingSavedObjectProps: { + aliasTargetId, + outcome, + errorJSON: + outcome === 'conflict' && getSpacesApi() + ? JSON.stringify({ + targetType: MAP_SAVED_OBJECT_TYPE, + sourceId: savedObjectId, + targetSpace: (await getSpacesApi()!.getActiveSpace()).id, + }) + : undefined, + }, + }; }, checkForDuplicateTitle: (props: OnSaveProps) => { return checkForDuplicateTitle( diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 8f6e74adfc2fd..ee3202ba022c9 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -20,8 +20,7 @@ import type { PluginInitializerContext, } from '../../../../src/core/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; -// @ts-ignore -import { MapView } from './inspector/views/map_view'; +import { MapInspectorView } from './inspector/map_inspector_view'; import { setEMSSettings, setKibanaCommonConfig, @@ -83,7 +82,8 @@ import { tileMapRenderer, tileMapVisType, } from './legacy_visualizations'; -import { SecurityPluginStart } from '../../security/public'; +import type { SecurityPluginStart } from '../../security/public'; +import type { SpacesPluginStart } from '../../spaces/public'; export interface MapsPluginSetupDependencies { expressions: ReturnType<ExpressionsPublicPlugin['setup']>; @@ -113,6 +113,7 @@ export interface MapsPluginStartDependencies { savedObjectsTagging?: SavedObjectTaggingPluginStart; presentationUtil: PresentationUtilPluginStart; security: SecurityPluginStart; + spaces?: SpacesPluginStart; } /** @@ -166,7 +167,7 @@ export class MapsPlugin }) ); - plugins.inspector.registerView(MapView); + plugins.inspector.registerView(MapInspectorView); if (plugins.home) { plugins.home.featureCatalogue.register(featureCatalogueEntry); } diff --git a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js index 402d7727cd6fe..9524d25a9a476 100644 --- a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js +++ b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js @@ -6,7 +6,7 @@ */ import { RequestAdapter } from '../../../../../src/plugins/inspector/common/adapters/request'; -import { MapAdapter } from '../inspector/adapters/map_adapter'; +import { MapAdapter } from '../inspector/map_adapter'; import { getShowMapsInspectorAdapter } from '../kibana_services'; const REGISTER_CANCEL_CALLBACK = 'REGISTER_CANCEL_CALLBACK'; diff --git a/x-pack/plugins/maps/public/render_app.tsx b/x-pack/plugins/maps/public/render_app.tsx index de67939b1a42a..3eefaeb6f7a9b 100644 --- a/x-pack/plugins/maps/public/render_app.tsx +++ b/x-pack/plugins/maps/public/render_app.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Router, Switch, Route, Redirect, RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { AppMountParameters } from 'kibana/public'; +import type { AppMountParameters } from 'kibana/public'; import { getCoreChrome, getCoreI18n, @@ -98,6 +98,7 @@ export async function renderApp( setHeaderActionMenu={setHeaderActionMenu} stateTransfer={stateTransfer} originatingApp={originatingApp} + history={history} key={routeProps.match.params.savedMapId ? routeProps.match.params.savedMapId : 'new'} /> ); diff --git a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx index 212fa89e2ad65..8fc2d97c4862a 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx @@ -18,6 +18,7 @@ import { getCoreChrome, getMapsCapabilities, getNavigation, + getSpacesApi, getTimeFilter, getToasts, } from '../../../kibana_services'; @@ -40,7 +41,8 @@ import { getIndexPatternsFromIds } from '../../../index_pattern_util'; import { getTopNavConfig } from '../top_nav_config'; import { goToSpecifiedPath } from '../../../render_app'; import { MapSavedObjectAttributes } from '../../../../common/map_saved_object_type'; -import { getFullPath, APP_ID } from '../../../../common/constants'; +import { getEditPath, getFullPath, APP_ID } from '../../../../common/constants'; +import { getMapEmbeddableDisplayName } from '../../../../common/i18n_getters'; import { getInitialQuery, getInitialRefreshConfig, @@ -85,6 +87,7 @@ export interface Props { isSaveDisabled: boolean; query: Query | undefined; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + history: AppMountParameters['history']; } export interface State { @@ -347,6 +350,16 @@ export class MapApp extends React.Component<Props, State> { return; } + const sharingSavedObjectProps = this.props.savedMap.getSharingSavedObjectProps(); + const spaces = getSpacesApi(); + if (spaces && sharingSavedObjectProps?.outcome === 'aliasMatch') { + // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash + const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch' + const newPath = `${getEditPath(newObjectId)}${this.props.history.location.hash}`; + await spaces.ui.redirectLegacyUrl(newPath, getMapEmbeddableDisplayName()); + return; + } + this.props.savedMap.setBreadcrumbs(); getCoreChrome().docTitle.change(this.props.savedMap.getTitle()); const savedObjectId = this.props.savedMap.getSavedObjectId(); @@ -437,6 +450,21 @@ export class MapApp extends React.Component<Props, State> { this._onFiltersChange([...this.props.filters, ...newFilters]); }; + _renderLegacyUrlConflict() { + const sharingSavedObjectProps = this.props.savedMap.getSharingSavedObjectProps(); + const spaces = getSpacesApi(); + return spaces && sharingSavedObjectProps?.outcome === 'conflict' + ? spaces.ui.components.getLegacyUrlConflict({ + objectNoun: getMapEmbeddableDisplayName(), + currentObjectId: this.props.savedMap.getSavedObjectId()!, + otherObjectId: sharingSavedObjectProps.aliasTargetId!, + otherObjectPath: `${getEditPath(sharingSavedObjectProps.aliasTargetId!)}${ + this.props.history.location.hash + }`, + }) + : null; + } + render() { if (!this.state.initialized) { return null; @@ -447,6 +475,7 @@ export class MapApp extends React.Component<Props, State> { {this._renderTopNav()} <h1 className="euiScreenReaderOnly">{`screenTitle placeholder`}</h1> <div id="react-maps-root"> + {this._renderLegacyUrlConflict()} <MapContainer addFilters={this._addFilter} title={this.props.savedMap.getAttributes().title} diff --git a/x-pack/plugins/maps/public/routes/map_page/map_page.tsx b/x-pack/plugins/maps/public/routes/map_page/map_page.tsx index 73ea62ef59d7c..7e927115a5d06 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_page.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_page.tsx @@ -7,8 +7,8 @@ import React, { Component } from 'react'; import { Provider } from 'react-redux'; -import { AppMountParameters } from 'kibana/public'; -import { EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; +import type { AppMountParameters } from 'kibana/public'; +import type { EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import { MapApp } from './map_app'; import { SavedMap, getInitialLayersFromUrlParam } from './saved_map'; import { MapEmbeddableInput } from '../../embeddable/types'; @@ -20,6 +20,7 @@ interface Props { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; stateTransfer: EmbeddableStateTransfer; originatingApp?: string; + history: AppMountParameters['history']; } interface State { @@ -69,6 +70,7 @@ export class MapPage extends Component<Props, State> { return ( <Provider store={this.state.savedMap.getStore()}> <MapApp + history={this.props.history} savedMap={this.state.savedMap} onAppLeave={this.props.onAppLeave} setHeaderActionMenu={this.props.setHeaderActionMenu} diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts index fab88af308f8d..004b88a242623 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts @@ -30,7 +30,7 @@ import { setHiddenLayers, } from '../../../actions'; import { getIsLayerTOCOpen, getOpenTOCDetails } from '../../../selectors/ui_selectors'; -import { getMapAttributeService } from '../../../map_attribute_service'; +import { getMapAttributeService, SharingSavedObjectProps } from '../../../map_attribute_service'; import { OnSaveProps } from '../../../../../../../src/plugins/saved_objects/public'; import { MapByReferenceInput, MapEmbeddableInput } from '../../../embeddable/types'; import { @@ -50,6 +50,7 @@ import { whenLicenseInitialized } from '../../../licensed_features'; export class SavedMap { private _attributes: MapSavedObjectAttributes | null = null; + private _sharingSavedObjectProps: SharingSavedObjectProps | null = null; private readonly _defaultLayers: LayerDescriptor[]; private readonly _embeddableId?: string; private _initialLayerListConfig: LayerDescriptor[] = []; @@ -98,8 +99,11 @@ export class SavedMap { }; } else { const doc = await getMapAttributeService().unwrapAttributes(this._mapEmbeddableInput); - const { references, ...savedObjectAttributes } = doc; + const { references, sharingSavedObjectProps, ...savedObjectAttributes } = doc; this._attributes = savedObjectAttributes; + if (sharingSavedObjectProps) { + this._sharingSavedObjectProps = sharingSavedObjectProps; + } const savedObjectsTagging = getSavedObjectsTagging(); if (savedObjectsTagging && references && references.length) { this._tags = savedObjectsTagging.ui.getTagIdsFromReferences(references); @@ -274,6 +278,10 @@ export class SavedMap { return this._attributes; } + public getSharingSavedObjectProps(): SharingSavedObjectProps | null { + return this._sharingSavedObjectProps; + } + public isByValue(): boolean { const hasSavedObjectId = !!this.getSavedObjectId(); return getIsAllowByValueEmbeddables() && !!this._originatingApp && !hasSavedObjectId; diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index 3f6e1fdbe8475..e2be2f3d66561 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -36,6 +36,7 @@ { "path": "../licensing/tsconfig.json" }, { "path": "../file_upload/tsconfig.json" }, { "path": "../saved_objects_tagging/tsconfig.json" }, - { "path": "../security/tsconfig.json" } + { "path": "../security/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" } ] } diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/index.ts b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/index.ts index a0a79c10f3ef2..fd87a7e816f67 100644 --- a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/index.ts +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/index.ts @@ -7,3 +7,4 @@ export { JobsAwaitingNodeWarning } from './jobs_awaiting_node_warning'; export { NewJobAwaitingNodeWarning } from './new_job_awaiting_node'; +export { MLJobsAwaitingNodeWarning } from './new_job_awaiting_node_shared'; diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/jobs_awaiting_node_warning.tsx b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/jobs_awaiting_node_warning.tsx index 2cc36b7a2adf7..2f51fd6e32a60 100644 --- a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/jobs_awaiting_node_warning.tsx +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/jobs_awaiting_node_warning.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, FC } from 'react'; +import React, { FC } from 'react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -21,7 +21,7 @@ export const JobsAwaitingNodeWarning: FC<Props> = ({ jobCount }) => { } return ( - <Fragment> + <> <EuiCallOut title={ <FormattedMessage @@ -43,6 +43,6 @@ export const JobsAwaitingNodeWarning: FC<Props> = ({ jobCount }) => { </div> </EuiCallOut> <EuiSpacer size="m" /> - </Fragment> + </> ); }; diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node.tsx b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node.tsx index ce31d1afc475b..f181497a9efc5 100644 --- a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node.tsx +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, FC } from 'react'; +import React, { FC } from 'react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -22,7 +22,7 @@ export const NewJobAwaitingNodeWarning: FC<Props> = () => { } return ( - <Fragment> + <> <EuiCallOut title={ <FormattedMessage @@ -41,6 +41,6 @@ export const NewJobAwaitingNodeWarning: FC<Props> = () => { </div> </EuiCallOut> <EuiSpacer size="m" /> - </Fragment> + </> ); }; diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/index.tsx b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/index.tsx new file mode 100644 index 0000000000000..0457e7dd18d3c --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/index.tsx @@ -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 { MLJobsAwaitingNodeWarning } from './lazy_loader'; diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/lazy_loader.tsx b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/lazy_loader.tsx new file mode 100644 index 0000000000000..655bde61ccc5d --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/lazy_loader.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 React, { FC } from 'react'; + +const MLJobsAwaitingNodeWarningComponent = React.lazy( + () => import('./new_job_awaiting_node_shared') +); + +interface Props { + jobIds: string[]; +} + +export const MLJobsAwaitingNodeWarning: FC<Props> = ({ jobIds }) => { + return ( + <React.Suspense fallback={<div />}> + <MLJobsAwaitingNodeWarningComponent jobIds={jobIds} /> + </React.Suspense> + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/new_job_awaiting_node_shared.tsx b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/new_job_awaiting_node_shared.tsx new file mode 100644 index 0000000000000..5850349ff5fd6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/new_job_awaiting_node_shared.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState, useEffect, useCallback, useMemo } from 'react'; +import { estypes } from '@elastic/elasticsearch'; + +import { EuiCallOut, EuiSpacer, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { JOB_STATE } from '../../../../../common/constants/states'; +import { mlApiServicesProvider } from '../../../services/ml_api_service'; +import { HttpService } from '../../../services/http_service'; +import { extractDeploymentId, CloudInfo } from '../../../services/ml_server_info'; + +interface Props { + jobIds: string[]; +} + +function isJobAwaitingNodeAssignment(job: estypes.MlJobStats) { + return job.node === undefined && job.state === JOB_STATE.OPENING; +} + +const MLJobsAwaitingNodeWarning: FC<Props> = ({ jobIds }) => { + const { http } = useKibana().services; + const ml = useMemo(() => mlApiServicesProvider(new HttpService(http!)), [http]); + + const [unassignedJobCount, setUnassignedJobCount] = useState<number>(0); + const [cloudInfo, setCloudInfo] = useState<CloudInfo | null>(null); + + const checkNodes = useCallback(async () => { + try { + if (jobIds.length === 0) { + setUnassignedJobCount(0); + return; + } + + const { lazyNodeCount } = await ml.mlNodeCount(); + if (lazyNodeCount === 0) { + setUnassignedJobCount(0); + return; + } + + const { jobs } = await ml.getJobStats({ jobId: jobIds.join(',') }); + const unassignedJobs = jobs.filter(isJobAwaitingNodeAssignment); + setUnassignedJobCount(unassignedJobs.length); + } catch (error) { + setUnassignedJobCount(0); + // eslint-disable-next-line no-console + console.error('Could not determine ML node information', error); + } + }, [jobIds]); + + const checkCloudInfo = useCallback(async () => { + if (unassignedJobCount === 0) { + return; + } + + try { + const resp = await ml.mlInfo(); + const cloudId = resp.cloudId ?? null; + setCloudInfo({ + isCloud: cloudId !== null, + cloudId, + deploymentId: cloudId === null ? null : extractDeploymentId(cloudId), + }); + } catch (error) { + setCloudInfo(null); + // eslint-disable-next-line no-console + console.error('Could not determine cloud information', error); + } + }, [unassignedJobCount]); + + useEffect(() => { + checkCloudInfo(); + }, [unassignedJobCount]); + + useEffect(() => { + checkNodes(); + }, [jobIds]); + + if (unassignedJobCount === 0) { + return null; + } + + return ( + <> + <EuiCallOut + title={ + <FormattedMessage + id="xpack.ml.jobsAwaitingNodeWarningShared.title" + defaultMessage="Awaiting machine learning node" + /> + } + color="primary" + iconType="iInCircle" + > + <div> + <FormattedMessage + id="xpack.ml.jobsAwaitingNodeWarningShared.noMLNodesAvailableDescription" + defaultMessage="There {jobCount, plural, one {is} other {are}} {jobCount, plural, one {# job} other {# jobs}} waiting for machine learning nodes to start." + values={{ + jobCount: unassignedJobCount, + }} + /> + <EuiSpacer size="s" /> + {cloudInfo && + (cloudInfo.isCloud ? ( + <> + <FormattedMessage + id="xpack.ml.jobsAwaitingNodeWarningShared.isCloud" + defaultMessage="Elastic Cloud deployments can autoscale to add more ML capacity. This may take 5-20 minutes. " + /> + {cloudInfo.deploymentId === null ? null : ( + <FormattedMessage + id="xpack.ml.jobsAwaitingNodeWarningShared.isCloud.link" + defaultMessage="You can monitor progress in the {link}." + values={{ + link: ( + <EuiLink + href={`https://cloud.elastic.co/deployments?q=${cloudInfo.deploymentId}`} + > + <FormattedMessage + id="xpack.ml.jobsAwaitingNodeWarningShared.linkToCloud.linkText" + defaultMessage="Elastic Cloud admin console" + /> + </EuiLink> + ), + }} + /> + )} + </> + ) : ( + <FormattedMessage + id="xpack.ml.jobsAwaitingNodeWarningShared.notCloud" + defaultMessage="Only Elastic Cloud deployments can autoscale; you must add machine learning nodes. {link}" + values={{ + link: ( + <EuiLink + href={ + 'https://www.elastic.co/guide/en/elasticsearch/reference/master/modules-node.html#ml-node' + } + > + <FormattedMessage + id="xpack.ml.jobsAwaitingNodeWarningShared.linkToCloud.learnMore" + defaultMessage="Learn more." + /> + </EuiLink> + ), + }} + /> + ))} + </div> + </EuiCallOut> + <EuiSpacer size="m" /> + </> + ); +}; + +// eslint-disable-next-line import/no-default-export +export default MLJobsAwaitingNodeWarning; diff --git a/x-pack/plugins/ml/public/application/services/ml_server_info.ts b/x-pack/plugins/ml/public/application/services/ml_server_info.ts index 21a1773206c43..c13ac94337490 100644 --- a/x-pack/plugins/ml/public/application/services/ml_server_info.ts +++ b/x-pack/plugins/ml/public/application/services/ml_server_info.ts @@ -11,6 +11,7 @@ import { MlServerDefaults, MlServerLimits } from '../../../common/types/ml_serve export interface CloudInfo { cloudId: string | null; isCloud: boolean; + deploymentId: string | null; } let defaults: MlServerDefaults = { @@ -22,6 +23,7 @@ let limits: MlServerLimits = {}; const cloudInfo: CloudInfo = { cloudId: null, isCloud: false, + deploymentId: null, }; export async function loadMlServerInfo() { @@ -31,6 +33,7 @@ export async function loadMlServerInfo() { limits = resp.limits; cloudInfo.cloudId = resp.cloudId || null; cloudInfo.isCloud = resp.cloudId !== undefined; + cloudInfo.deploymentId = !resp.cloudId ? null : extractDeploymentId(resp.cloudId); return { defaults, limits, cloudId: cloudInfo }; } catch (error) { return { defaults, limits, cloudId: cloudInfo }; @@ -54,7 +57,7 @@ export function isCloud(): boolean { } export function getCloudDeploymentId(): string | null { - return cloudInfo.cloudId === null ? null : extractDeploymentId(cloudInfo.cloudId); + return cloudInfo.deploymentId; } export function extractDeploymentId(cloudId: string) { diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index 78090c611b479..6af8b8a6c876d 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -64,3 +64,5 @@ export const getMlSharedImports = async () => { // Helper to get Type returned by getMlSharedImports. type AwaitReturnType<T> = T extends PromiseLike<infer U> ? U : T; export type GetMlSharedImportsReturnType = AwaitReturnType<ReturnType<typeof getMlSharedImports>>; + +export { MLJobsAwaitingNodeWarning } from './application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared'; diff --git a/x-pack/plugins/monitoring/public/alerts/enable_alerts_modal.tsx b/x-pack/plugins/monitoring/public/alerts/enable_alerts_modal.tsx index 780997ca98191..3e45262610f45 100644 --- a/x-pack/plugins/monitoring/public/alerts/enable_alerts_modal.tsx +++ b/x-pack/plugins/monitoring/public/alerts/enable_alerts_modal.tsx @@ -23,6 +23,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { Legacy } from '../legacy_shims'; +import { useAlertsModal } from '../application/hooks/use_alerts_modal'; interface Props { alerts: {}; @@ -30,8 +31,7 @@ interface Props { export const EnableAlertsModal: React.FC<Props> = ({ alerts }: Props) => { const [isModalVisible, setIsModalVisible] = useState(false); - const $injector = Legacy.shims.getAngularInjector(); - const alertsEnableModalProvider: any = $injector.get('enableAlertsModal'); + const alertsEnableModalProvider = useAlertsModal(); const closeModal = () => { setIsModalVisible(false); diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts b/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts new file mode 100644 index 0000000000000..9a2a2b80cc40f --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.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 { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { showAlertsToast } from '../../alerts/lib/alerts_toast'; +import { ajaxErrorHandlersProvider } from '../../lib/ajax_error_handler'; + +export const useAlertsModal = () => { + const { services } = useKibana(); + + function shouldShowAlertsModal(alerts: {}) { + const modalHasBeenShown = + window.sessionStorage.getItem('ALERTS_MODAL_HAS_BEEN_SHOWN') === 'true'; + const decisionMade = window.localStorage.getItem('ALERTS_MODAL_DECISION_MADE') === 'true'; + + if (Object.keys(alerts).length > 0) { + window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', 'true'); + return false; + } else if (!modalHasBeenShown && !decisionMade) { + return true; + } + + return false; + } + + async function enableAlerts() { + try { + const { data } = await services.http?.post('../api/monitoring/v1/alerts/enable', {}); + window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', 'true'); + showAlertsToast(data); + } catch (err) { + const ajaxErrorHandlers = ajaxErrorHandlersProvider(); + return ajaxErrorHandlers(err); + } + } + + function notAskAgain() { + window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', 'true'); + } + + function hideModalForSession() { + window.sessionStorage.setItem('ALERTS_MODAL_HAS_BEEN_SHOWN', 'true'); + } + + return { + shouldShowAlertsModal, + enableAlerts, + notAskAgain, + hideModalForSession, + }; +}; diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts index e11317fd92bde..b4b8c21ca4d40 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts @@ -6,7 +6,7 @@ */ import { useState, useEffect } from 'react'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../common/constants'; +import { fetchClusters } from '../../lib/fetch_clusters'; export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: string[]) { const { services } = useKibana<{ data: any }>(); @@ -18,47 +18,29 @@ export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: const [clusters, setClusters] = useState([] as any); const [loaded, setLoaded] = useState<boolean | null>(false); - let url = '../api/monitoring/v1/clusters'; - if (clusterUuid) { - url += `/${clusterUuid}`; - } - useEffect(() => { - const fetchClusters = async () => { + async function makeRequest() { try { - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch) { + const response = await fetchClusters({ timeRange: { min, max, }, + fetch: services.http.fetch, + clusterUuid, codePaths, - }), - }); - - setClusters(formatClusters(response)); - } catch (err) { - // TODO: handle errors + }); + setClusters(response); + } + } catch (e) { + // TODO: Handle errors } finally { setLoaded(true); } - }; - - fetchClusters(); - }, [ccs, services.http, codePaths, url, min, max]); + } + makeRequest(); + }, [clusterUuid, ccs, services.http, codePaths, min, max]); return { clusters, loaded }; } - -function formatClusters(clusters: any) { - return clusters.map(formatCluster); -} - -function formatCluster(cluster: any) { - if (cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID) { - cluster.cluster_name = 'Standalone Cluster'; - } - return cluster; -} diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index 9ecfca2f2df2e..f7b89fa640bc9 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -13,6 +13,7 @@ import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/p import { LoadingPage } from './pages/loading_page'; import { LicensePage } from './pages/license_page'; import { ClusterOverview } from './pages/cluster/overview_page'; +import { ClusterListing } from './pages/home/cluster_listing'; import { MonitoringStartPluginDependencies } from '../types'; import { GlobalStateProvider } from './global_state_context'; import { ExternalConfigContext, ExternalConfig } from './external_config_context'; @@ -21,10 +22,18 @@ import { RouteInit } from './route_init'; import { NoDataPage } from './pages/no_data'; import { ElasticsearchOverviewPage } from './pages/elasticsearch/overview'; import { BeatsOverviewPage } from './pages/beats/overview'; -import { BeatsInstancesPage } from './pages/beats/instances'; +import { + CODE_PATH_ELASTICSEARCH, + CODE_PATH_BEATS, + CODE_PATH_KIBANA, + CODE_PATH_LOGSTASH, + CODE_PATH_APM, +} from '../../common/constants'; import { BeatsInstancePage } from './pages/beats/instance'; import { ApmOverviewPage, ApmInstancesPage, ApmInstancePage } from './pages/apm'; import { KibanaOverviewPage } from './pages/kibana/overview'; +import { KibanaInstancesPage } from './pages/kibana/instances'; +import { KibanaInstancePage } from './pages/kibana/instance'; import { ElasticsearchNodesPage } from './pages/elasticsearch/nodes_page'; import { ElasticsearchIndicesPage } from './pages/elasticsearch/indices_page'; import { ElasticsearchIndexPage } from './pages/elasticsearch/index_page'; @@ -33,12 +42,15 @@ import { ElasticsearchNodePage } from './pages/elasticsearch/node_page'; import { ElasticsearchNodeAdvancedPage } from './pages/elasticsearch/node_advanced_page'; import { MonitoringTimeContainer } from './hooks/use_monitoring_time'; import { BreadcrumbContainer } from './hooks/use_breadcrumbs'; -import { - CODE_PATH_ELASTICSEARCH, - CODE_PATH_KIBANA, - CODE_PATH_BEATS, - CODE_PATH_APM, -} from '../../common/constants'; +import { LogStashOverviewPage } from './pages/logstash/overview'; +import { LogStashNodesPage } from './pages/logstash/nodes'; +import { LogStashPipelinesPage } from './pages/logstash/pipelines'; +import { LogStashPipelinePage } from './pages/logstash/pipeline'; +import { BeatsInstancesPage } from './pages/beats/instances'; +import { LogStashNodeAdvancedPage } from './pages/logstash/advanced'; +// import { LogStashNodePipelinesPage } from './pages/logstash/node_pipelines'; +import { LogStashNodePage } from './pages/logstash/node'; +import { LogStashNodePipelinesPage } from './pages/logstash/node_pipelines'; export const renderApp = ( core: CoreStart, @@ -81,9 +93,10 @@ const MonitoringApp: React.FC<{ /> <RouteInit path="/home" - component={Home} + component={ClusterListing} codePaths={['all']} - fetchAllClusters={false} + fetchAllClusters={true} + unsetGlobalState={true} /> <RouteInit path="/overview" @@ -143,6 +156,20 @@ const MonitoringApp: React.FC<{ /> {/* Kibana Views */} + <RouteInit + path="/kibana/instances/:instance" + component={KibanaInstancePage} + codePaths={[CODE_PATH_KIBANA]} + fetchAllClusters={false} + /> + + <RouteInit + path="/kibana/instances" + component={KibanaInstancesPage} + codePaths={[CODE_PATH_KIBANA]} + fetchAllClusters={false} + /> + <RouteInit path="/kibana" component={KibanaOverviewPage} @@ -172,6 +199,14 @@ const MonitoringApp: React.FC<{ fetchAllClusters={false} /> + {/* Logstash Routes */} + <RouteInit + path="/logstash/nodes" + component={LogStashNodesPage} + codePaths={[CODE_PATH_LOGSTASH]} + fetchAllClusters={false} + /> + {/* APM Views */} <RouteInit path="/apm/instances/:instance" @@ -180,6 +215,20 @@ const MonitoringApp: React.FC<{ fetchAllClusters={false} /> + {/* Logstash Routes */} + <RouteInit + path="/logstash/nodes" + component={LogStashNodesPage} + codePaths={[CODE_PATH_LOGSTASH]} + fetchAllClusters={false} + /> + + <RouteInit + path="/logstash/node/:uuid/advanced" + component={LogStashNodeAdvancedPage} + codePaths={[CODE_PATH_LOGSTASH]} + fetchAllClusters={false} + /> <RouteInit path="/apm/instances" component={ApmInstancesPage} @@ -187,6 +236,40 @@ const MonitoringApp: React.FC<{ fetchAllClusters={false} /> + <RouteInit + path="/logstash/node/:uuid/pipelines" + component={LogStashNodePipelinesPage} + codePaths={[CODE_PATH_LOGSTASH]} + fetchAllClusters={false} + /> + + <RouteInit + path="/logstash/node/:uuid" + component={LogStashNodePage} + codePaths={[CODE_PATH_LOGSTASH]} + fetchAllClusters={false} + /> + + <RouteInit + path="/logstash/pipelines/:id/:hash?" + component={LogStashPipelinePage} + codePaths={[CODE_PATH_LOGSTASH]} + fetchAllClusters={false} + /> + + <RouteInit + path="/logstash/pipelines" + component={LogStashPipelinesPage} + codePaths={[CODE_PATH_LOGSTASH]} + fetchAllClusters={false} + /> + + <RouteInit + path="/logstash" + component={LogStashOverviewPage} + codePaths={[CODE_PATH_LOGSTASH]} + fetchAllClusters={false} + /> <RouteInit path="/apm" component={ApmOverviewPage} @@ -209,7 +292,3 @@ const MonitoringApp: React.FC<{ </KibanaContextProvider> ); }; - -const Home: React.FC<{}> = () => { - return <div>Home page (Cluster listing)</div>; -}; diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx index 7a65022d8ff53..29945f0fe725c 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx @@ -15,16 +15,10 @@ import { useTable } from '../../hooks/use_table'; import { BeatsTemplate } from './beats_template'; // @ts-ignore import { Listing } from '../../../components/beats/listing'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -interface SetupModeProps { - setupMode: any; - flyoutComponent: any; - bottomBarComponent: any; -} - export const BeatsInstancesPage: React.FC<ComponentProps> = ({ clusters }) => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); diff --git a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx index 3167cd76195c9..4e2c375a5896f 100644 --- a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx @@ -14,17 +14,12 @@ import { GlobalStateContext } from '../../global_state_context'; import { TabMenuItem } from '../page_template'; import { Overview } from '../../../components/cluster/overview'; import { ExternalConfigContext } from '../../external_config_context'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; +import { fetchClusters } from '../../../lib/fetch_clusters'; const CODE_PATHS = [CODE_PATH_ALL]; -interface SetupModeProps { - setupMode: any; - flyoutComponent: any; - bottomBarComponent: any; -} export const ClusterOverview: React.FC<{}> = () => { const state = useContext(GlobalStateContext); @@ -59,25 +54,20 @@ export const ClusterOverview: React.FC<{}> = () => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); - let url = '../api/monitoring/v1/clusters'; - if (clusterUuid) { - url += `/${clusterUuid}`; - } - try { - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch) { + const response = await fetchClusters({ + fetch: services.http.fetch, timeRange: { min: bounds.min.toISOString(), max: bounds.max.toISOString(), }, + ccs, + clusterUuid, codePaths: CODE_PATHS, - }), - }); - - setClusters(formatClusters(response)); + }); + setClusters(response); + } } finally { setLoaded(true); } @@ -109,14 +99,3 @@ export const ClusterOverview: React.FC<{}> = () => { </PageTemplate> ); }; - -function formatClusters(clusters: any) { - return clusters.map(formatCluster); -} - -function formatCluster(cluster: any) { - if (cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID) { - cluster.cluster_name = 'Standalone Cluster'; - } - return cluster; -} diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx index ccaf23c7ade8e..a55e0b5df9648 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx @@ -10,19 +10,13 @@ import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { GlobalStateContext } from '../../global_state_context'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useCharts } from '../../hooks/use_charts'; import { ItemTemplate } from './item_template'; // @ts-ignore import { AdvancedIndex } from '../../../components/elasticsearch/index/advanced'; -interface SetupModeProps { - setupMode: any; - flyoutComponent: any; - bottomBarComponent: any; -} - export const ElasticsearchIndexAdvancedPage: React.FC<ComponentProps> = ({ clusters }) => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx index b23f9c71a98bf..4f659f6c1354e 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx @@ -12,7 +12,7 @@ import { GlobalStateContext } from '../../global_state_context'; // @ts-ignore import { IndexReact } from '../../../components/elasticsearch/index/index_react'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useCharts } from '../../hooks/use_charts'; import { ItemTemplate } from './item_template'; @@ -21,12 +21,6 @@ import { indicesByNodes } from '../../../components/elasticsearch/shard_allocati // @ts-ignore import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; -interface SetupModeProps { - setupMode: any; - flyoutComponent: any; - bottomBarComponent: any; -} - export const ElasticsearchIndexPage: React.FC<ComponentProps> = ({ clusters }) => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx index 9166f2090d89a..8d5f7bfebc2b3 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx @@ -12,17 +12,11 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public' import { GlobalStateContext } from '../../global_state_context'; import { ElasticsearchIndices } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; import { useLocalStorage } from '../../hooks/use_local_storage'; -interface SetupModeProps { - setupMode: any; - flyoutComponent: any; - bottomBarComponent: any; -} - export const ElasticsearchIndicesPage: React.FC<ComponentProps> = ({ clusters }) => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx index a8825f377eada..58acd77afc622 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx @@ -12,7 +12,7 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public' import { GlobalStateContext } from '../../global_state_context'; import { NodeReact } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useLocalStorage } from '../../hooks/use_local_storage'; import { useCharts } from '../../hooks/use_charts'; @@ -20,12 +20,6 @@ import { nodesByIndices } from '../../../components/elasticsearch/shard_allocati // @ts-ignore import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; -interface SetupModeProps { - setupMode: any; - flyoutComponent: any; - bottomBarComponent: any; -} - export const ElasticsearchNodePage: React.FC<ComponentProps> = ({ clusters }) => { const globalState = useContext(GlobalStateContext); const { zoomInfo, onBrush } = useCharts(); diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/nodes_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/nodes_page.tsx index 1fee700b4d920..d91b8b0441c59 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/nodes_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/nodes_page.tsx @@ -13,17 +13,11 @@ import { GlobalStateContext } from '../../global_state_context'; import { ExternalConfigContext } from '../../external_config_context'; import { ElasticsearchNodes } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -interface SetupModeProps { - setupMode: any; - flyoutComponent: any; - bottomBarComponent: any; -} - export const ElasticsearchNodesPage: React.FC<ComponentProps> = ({ clusters }) => { const globalState = useContext(GlobalStateContext); const { showCgroupMetricsElasticsearch } = useContext(ExternalConfigContext); diff --git a/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx b/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx new file mode 100644 index 0000000000000..906db1b57f0f5 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx @@ -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 React, { useCallback, useContext, useEffect, useState } from 'react'; +import { Redirect } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +// @ts-ignore +import { Listing } from '../../../components/cluster/listing'; +import { EnableAlertsModal } from '../../../alerts/enable_alerts_modal'; +import { GlobalStateContext } from '../../global_state_context'; +import { ExternalConfigContext } from '../../external_config_context'; +import { ComponentProps } from '../../route_init'; +import { useTable } from '../../hooks/use_table'; +import { PageTemplate, TabMenuItem } from '../page_template'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; +import { fetchClusters } from '../../../lib/fetch_clusters'; + +const pageTitle = i18n.translate('xpack.monitoring.cluster.listing.pageTitle', { + defaultMessage: 'Cluster listing', +}); + +const tabTitle = i18n.translate('xpack.monitoring.cluster.listing.tabTitle', { + defaultMessage: 'Clusters', +}); + +const getAlerts = (clusters: any[]) => { + return clusters.reduce( + (alerts, cluster) => ({ ...alerts, ...((cluster.alerts && cluster.alerts.list) || {}) }), + {} + ); +}; + +export const ClusterListing: React.FC<ComponentProps> = () => { + const globalState = useContext(GlobalStateContext); + const externalConfig = useContext(ExternalConfigContext); + const { services } = useKibana<{ data: any }>(); + const [clusters, setClusters] = useState([] as any); + const { update: updateBreadcrumbs } = useContext(BreadcrumbContainer.Context); + + const fakeScope = { + $evalAsync: (fn: () => void) => fn(), + filterQuery: '', // replace with something + }; + const { getPaginationTableProps } = useTable('clusters'); + const { sorting, pagination, onTableChange } = getPaginationTableProps(); + + useEffect(() => { + updateBreadcrumbs([ + { + 'data-test-subj': 'clusterListingBreadcrumb', + text: tabTitle, + }, + ]); + }, [updateBreadcrumbs]); + + const tabs: TabMenuItem[] = [ + { + id: 'clusters', + label: tabTitle, + testSubj: 'clusterListingTab', + route: '/home', + }, + ]; + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + try { + if (services.http?.fetch) { + const response = await fetchClusters({ + fetch: services.http.fetch, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + ccs: globalState.ccs, + codePaths: ['all'], + }); + setClusters(response); + } + } catch (err) { + // TODO: handle errors + } + }, [globalState, services.data?.query.timefilter.timefilter, services.http]); + + if (globalState.save && clusters.length === 1) { + globalState.cluster_uuid = clusters[0].cluster_uuid; + globalState.save(); + } + + return ( + <PageTemplate tabs={tabs} title={pageTitle} pageTitle={pageTitle} getPageData={getPageData}> + {clusters.length === 1 && <Redirect to={{ pathname: '/overview' }} />} + <Listing + clusters={clusters} + angular={{ + scope: fakeScope, + globalState, + storage: { + get: (key: string) => window.localStorage.getItem(key), + set: (key: string, value: string) => window.localStorage.setItem(key, value), + }, + showLicenseExpiration: externalConfig.showLicenseExpiration, + }} + sorting={sorting} + pagination={pagination} + onTableChange={onTableChange} + /> + <EnableAlertsModal alerts={getAlerts(clusters)} /> + </PageTemplate> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx new file mode 100644 index 0000000000000..8b88fc47a9007 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx @@ -0,0 +1,163 @@ +/* + * 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, { useContext, useState, useCallback, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiFlexGrid, + EuiFlexItem, + EuiPanel, +} from '@elastic/eui'; +import { ComponentProps } from '../../route_init'; +import { GlobalStateContext } from '../../global_state_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useCharts } from '../../hooks/use_charts'; +// @ts-ignore +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; +// @ts-ignore +import { MonitoringTimeseriesContainer } from '../../../components/chart'; +// @ts-ignore +import { DetailStatus } from '../../../components/kibana/detail_status'; +import { PageTemplate } from '../page_template'; +import { AlertsCallout } from '../../../alerts/callout'; + +const KibanaInstance = ({ data, alerts }: { data: any; alerts: any }) => { + const { zoomInfo, onBrush } = useCharts(); + + return ( + <EuiPage> + <EuiPageBody> + <EuiPanel> + <DetailStatus stats={data.kibanaSummary} /> + </EuiPanel> + <EuiSpacer size="m" /> + <AlertsCallout alerts={alerts} /> + <EuiPageContent> + <EuiFlexGrid columns={2} gutterSize="s"> + <EuiFlexItem grow={true}> + <MonitoringTimeseriesContainer + series={data.metrics.kibana_requests} + onBrush={onBrush} + zoomInfo={zoomInfo} + /> + <EuiSpacer /> + </EuiFlexItem> + <EuiFlexItem grow={true}> + <MonitoringTimeseriesContainer + series={data.metrics.kibana_response_times} + onBrush={onBrush} + zoomInfo={zoomInfo} + /> + <EuiSpacer /> + </EuiFlexItem> + <EuiFlexItem grow={true}> + <MonitoringTimeseriesContainer + series={data.metrics.kibana_memory} + onBrush={onBrush} + zoomInfo={zoomInfo} + /> + <EuiSpacer /> + </EuiFlexItem> + <EuiFlexItem grow={true}> + <MonitoringTimeseriesContainer + series={data.metrics.kibana_average_concurrent_connections} + onBrush={onBrush} + zoomInfo={zoomInfo} + /> + <EuiSpacer /> + </EuiFlexItem> + <EuiFlexItem grow={true}> + <MonitoringTimeseriesContainer + series={data.metrics.kibana_os_load} + onBrush={onBrush} + zoomInfo={zoomInfo} + /> + <EuiSpacer /> + </EuiFlexItem> + <EuiFlexItem grow={true}> + <MonitoringTimeseriesContainer + series={data.metrics.kibana_process_delay} + onBrush={onBrush} + zoomInfo={zoomInfo} + /> + <EuiSpacer /> + </EuiFlexItem> + </EuiFlexGrid> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + ); +}; + +export const KibanaInstancePage: React.FC<ComponentProps> = ({ clusters }) => { + const { instance }: { instance: string } = useParams(); + + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); + const clusterUuid = globalState.cluster_uuid; + const ccs = globalState.ccs; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + const [data, setData] = useState({} as any); + const [instanceName, setInstanceName] = useState(''); + + const title = `Kibana - ${instanceName}`; + const pageTitle = i18n.translate('xpack.monitoring.kibana.instance.pageTitle', { + defaultMessage: 'Kibana instance: {instance}', + values: { + instance: instanceName, + }, + }); + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inKibana: true, + instance: instanceName, + }); + } + }, [cluster, instanceName, generateBreadcrumbs]); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/kibana/${instance}`; + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + setInstanceName(response.kibanaSummary.name); + }, [ccs, clusterUuid, instance, services.data?.query.timefilter.timefilter, services.http]); + + return ( + <PageTemplate + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + data-test-subj="kibanaInstancePage" + > + <div data-test-subj="monitoringKibanaInstanceApp"> + <KibanaInstance data={data} alerts={{}} /> + </div> + </PageTemplate> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx new file mode 100644 index 0000000000000..12f3214b73693 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx @@ -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 React, { useContext, useState, useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { ComponentProps } from '../../route_init'; +import { GlobalStateContext } from '../../global_state_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useTable } from '../../hooks/use_table'; +import { KibanaTemplate } from './kibana_template'; +// @ts-ignore +import { KibanaInstances } from '../../../components/kibana/instances'; +// @ts-ignore +import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; + +export const KibanaInstancesPage: React.FC<ComponentProps> = ({ clusters }) => { + const { cluster_uuid: clusterUuid, ccs } = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); + const { updateTotalItemCount, getPaginationTableProps } = useTable('kibana.instances'); + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + const [data, setData] = useState({} as any); + + const title = i18n.translate('xpack.monitoring.kibana.instances.routeTitle', { + defaultMessage: 'Kibana - Instances', + }); + + const pageTitle = i18n.translate('xpack.monitoring.kibana.instances.pageTitle', { + defaultMessage: 'Kibana instances', + }); + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inKibana: true, + }); + } + }, [cluster, generateBreadcrumbs]); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/kibana/instances`; + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + updateTotalItemCount(response.stats.total); + }, [ + ccs, + clusterUuid, + services.data?.query.timefilter.timefilter, + services.http, + updateTotalItemCount, + ]); + + return ( + <KibanaTemplate + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + data-test-subj="kibanaInstancesPage" + > + <div data-test-subj="monitoringKibanaInstancesApp"> + <SetupModeRenderer + productName="kibana" + render={({ setupMode, flyoutComponent, bottomBarComponent }: SetupModeProps) => ( + <SetupModeContext.Provider value={{ setupModeSupported: true }}> + {flyoutComponent} + <KibanaInstances + alerts={{}} + instances={data.kibanas} + setupMode={setupMode} + clusterStatus={data.clusterStatus} + {...getPaginationTableProps()} + /> + {bottomBarComponent} + </SetupModeContext.Provider> + )} + /> + </div> + </KibanaTemplate> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/advanced.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/advanced.tsx new file mode 100644 index 0000000000000..8a33190d39c94 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/advanced.tsx @@ -0,0 +1,128 @@ +/* + * 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, { useContext, useState, useCallback, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { + EuiPage, + EuiPageBody, + EuiPanel, + EuiSpacer, + EuiPageContent, + EuiFlexGrid, + EuiFlexItem, +} from '@elastic/eui'; +import { useRouteMatch } from 'react-router-dom'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +import { ComponentProps } from '../../route_init'; +// @ts-ignore +import { Listing } from '../../../components/logstash/listing'; +import { LogstashTemplate } from './logstash_template'; +// @ts-ignore +import { DetailStatus } from '../../../components/logstash/detail_status'; +// @ts-ignore +import { MonitoringTimeseriesContainer } from '../../../components/chart'; +import { AlertsCallout } from '../../../alerts/callout'; +import { useCharts } from '../../hooks/use_charts'; + +export const LogStashNodeAdvancedPage: React.FC<ComponentProps> = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const match = useRouteMatch<{ uuid: string | undefined }>(); + const { services } = useKibana<{ data: any }>(); + const clusterUuid = globalState.cluster_uuid; + const { zoomInfo, onBrush } = useCharts(); + const ccs = globalState.ccs; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }); + + const [data, setData] = useState({} as any); + + const title = i18n.translate('xpack.monitoring.logstash.node.advanced.routeTitle', { + defaultMessage: 'Logstash - {nodeName} - Advanced', + values: { + nodeName: data.nodeSummary ? data.nodeSummary.name : '', + }, + }); + + const pageTitle = i18n.translate('xpack.monitoring.logstash.node.advanced.pageTitle', { + defaultMessage: 'Logstash node: {nodeName}', + values: { + nodeName: data.nodeSummary ? data.nodeSummary.name : '', + }, + }); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/node/${match.params.uuid}`; + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: true, + }), + }); + + setData(response); + }, [ + ccs, + clusterUuid, + services.data?.query.timefilter.timefilter, + services.http, + match.params.uuid, + ]); + + const metricsToShow = useMemo(() => { + if (!data.metrics) return []; + + return [ + data.metrics.logstash_node_cpu_utilization, + data.metrics.logstash_queue_events_count, + data.metrics.logstash_node_cgroup_cpu, + data.metrics.logstash_pipeline_queue_size, + data.metrics.logstash_node_cgroup_stats, + ]; + }, [data.metrics]); + + return ( + <LogstashTemplate + instance={data} + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + cluster={cluster} + > + <EuiPage> + <EuiPageBody> + <EuiPanel>{data.nodeSummary && <DetailStatus stats={data.nodeSummary} />}</EuiPanel> + <EuiSpacer size="m" /> + <AlertsCallout alerts={{}} /> + <EuiPageContent> + <EuiFlexGrid columns={2} gutterSize="s"> + {metricsToShow.map((metric, index) => ( + <EuiFlexItem key={index}> + <MonitoringTimeseriesContainer + series={metric} + onBrush={onBrush} + zoomInfo={zoomInfo} + {...data} + /> + <EuiSpacer /> + </EuiFlexItem> + ))} + </EuiFlexGrid> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + </LogstashTemplate> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/logstash_template.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/logstash_template.tsx new file mode 100644 index 0000000000000..d1b3c5e5ec374 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/logstash_template.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { PageTemplate } from '../page_template'; +import { TabMenuItem, PageTemplateProps } from '../page_template'; + +interface LogstashTemplateProps extends PageTemplateProps { + cluster: any; + instance?: any; + pipelineId?: string; + pipelineVersions?: string[]; + tabsDisabled?: boolean; +} + +export const LogstashTemplate: React.FC<LogstashTemplateProps> = ({ + cluster, + instance, + pipelineId, + pipelineVersions, + tabsDisabled, + ...props +}) => { + const tabs: TabMenuItem[] = []; + if (!tabsDisabled) { + if (!instance && !pipelineId) { + tabs.push({ + id: 'overview', + label: i18n.translate('xpack.monitoring.logstashNavigation.overviewLinkText', { + defaultMessage: 'Overview', + }), + route: '/logstash', + }); + tabs.push({ + id: 'nodes', + label: i18n.translate('xpack.monitoring.logstashNavigation.nodesLinkText', { + defaultMessage: 'Nodes', + }), + route: '/logstash/nodes', + }); + tabs.push({ + id: 'pipelines', + label: i18n.translate('xpack.monitoring.logstashNavigation.pipelinesLinkText', { + defaultMessage: 'Pipelines', + }), + route: '/logstash/pipelines', + }); + } else if (instance) { + tabs.push({ + id: 'overview', + label: i18n.translate('xpack.monitoring.logstashNavigation.instance.overviewLinkText', { + defaultMessage: 'Overview', + }), + route: `/logstash/node/${instance.nodeSummary?.uuid}`, // IDK if this is right + }); + tabs.push({ + id: 'pipeline', + label: i18n.translate('xpack.monitoring.logstashNavigation.instance.pipelinesLinkText', { + defaultMessage: 'Pipelines', + }), + route: `/logstash/node/${instance.nodeSummary?.uuid}/pipelines`, // IDK if this is right + }); + tabs.push({ + id: 'advanced', + label: i18n.translate('xpack.monitoring.logstashNavigation.instance.advancedLinkText', { + defaultMessage: 'Advanced', + }), + route: `/logstash/node/${instance.nodeSummary?.uuid}/advanced`, // IDK if this is right + }); + } + } + + if (pipelineVersions && pipelineVersions.length) { + // todo add this in: https://github.com/elastic/kibana/blob/4584a8b570402aa07832cf3e5b520e5d2cfa7166/x-pack/plugins/monitoring/public/directives/main/index.js#L36, https://github.com/elastic/kibana/blob/c07a512e4647a40d8e891eb24f5912783b561fba/x-pack/plugins/monitoring/public/directives/main/index.html#L293-L298 + // tabs.push({ + // id: 'dropdown-elm', + // }) + } + + return <PageTemplate {...props} tabs={tabs} product="logstash" />; +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx new file mode 100644 index 0000000000000..301d3c45dedb5 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx @@ -0,0 +1,122 @@ +/* + * 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, { useContext, useState, useCallback, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { useRouteMatch } from 'react-router-dom'; +import { + EuiPage, + EuiPageBody, + EuiPanel, + EuiSpacer, + EuiPageContent, + EuiFlexGrid, + EuiFlexItem, +} from '@elastic/eui'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +import { ComponentProps } from '../../route_init'; +// @ts-ignore +import { List } from '../../../components/logstash/pipeline_viewer/models/list'; +// @ts-ignore +import { LogstashTemplate } from './logstash_template'; +// @ts-ignore +import { DetailStatus } from '../../../components/logstash/detail_status'; +// @ts-ignore +import { MonitoringTimeseriesContainer } from '../../../components/chart'; +import { AlertsCallout } from '../../../alerts/callout'; +import { useCharts } from '../../hooks/use_charts'; + +export const LogStashNodePage: React.FC<ComponentProps> = ({ clusters }) => { + const match = useRouteMatch<{ uuid: string | undefined }>(); + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const clusterUuid = globalState.cluster_uuid; + const ccs = globalState.ccs; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }); + const [data, setData] = useState({} as any); + const { zoomInfo, onBrush } = useCharts(); + const title = i18n.translate('xpack.monitoring.logstash.node.routeTitle', { + defaultMessage: 'Logstash - {nodeName}', + values: { + nodeName: data.nodeSummary ? data.nodeSummary.name : '', + }, + }); + + const pageTitle = i18n.translate('xpack.monitoring.logstash.node.pageTitle', { + defaultMessage: 'Logstash node: {nodeName}', + values: { + nodeName: data.nodeSummary ? data.nodeSummary.name : '', + }, + }); + + const getPageData = useCallback(async () => { + const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/node/${match.params.uuid}`; + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: false, + }), + }); + + setData(response); + }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http, match.params]); + + const metricsToShow = useMemo(() => { + if (!data.metrics) return []; + + return [ + data.metrics.logstash_events_input_rate, + data.metrics.logstash_jvm_usage, + data.metrics.logstash_events_output_rate, + data.metrics.logstash_node_cpu_metric, + data.metrics.logstash_events_latency, + data.metrics.logstash_os_load, + ]; + }, [data.metrics]); + + return ( + <LogstashTemplate + instance={data} + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + cluster={cluster} + > + <EuiPage> + <EuiPageBody> + <EuiPanel>{data.nodeSummary && <DetailStatus stats={data.nodeSummary} />}</EuiPanel> + <EuiSpacer size="m" /> + <AlertsCallout alerts={{}} /> + <EuiPageContent> + <EuiFlexGrid columns={2} gutterSize="s"> + {metricsToShow.map((metric, index) => ( + <EuiFlexItem key={index}> + <MonitoringTimeseriesContainer + series={metric} + onBrush={onBrush} + zoomInfo={zoomInfo} + {...data} + /> + <EuiSpacer /> + </EuiFlexItem> + ))} + </EuiFlexGrid> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + </LogstashTemplate> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx new file mode 100644 index 0000000000000..1c956603f99bd --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx @@ -0,0 +1,122 @@ +/* + * 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, { useContext, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +// @ts-ignore +import { useRouteMatch } from 'react-router-dom'; +// @ts-ignore +import { isPipelineMonitoringSupportedInVersion } from '../../../lib/logstash/pipelines'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +import { ComponentProps } from '../../route_init'; +// @ts-ignore +import { Listing } from '../../../components/logstash/listing'; +import { LogstashTemplate } from './logstash_template'; +// @ts-ignore +import { DetailStatus } from '../../../components/logstash/detail_status'; +// @ts-ignore +import { MonitoringTimeseriesContainer } from '../../../components/chart'; +import { useTable } from '../../hooks/use_table'; +// @ts-ignore +import { PipelineListing } from '../../../components/logstash/pipeline_listing/pipeline_listing'; +import { useCharts } from '../../hooks/use_charts'; + +export const LogStashNodePipelinesPage: React.FC<ComponentProps> = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const match = useRouteMatch<{ uuid: string | undefined }>(); + const { services } = useKibana<{ data: any }>(); + const clusterUuid = globalState.cluster_uuid; + const ccs = globalState.ccs; + const { onBrush, zoomInfo } = useCharts(); + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }); + + const { getPaginationTableProps, getPaginationRouteOptions, updateTotalItemCount } = + useTable('logstash.pipelines'); + + const [data, setData] = useState({} as any); + + const title = i18n.translate('xpack.monitoring.logstash.node.pipelines.routeTitle', { + defaultMessage: 'Logstash - {nodeName} - Pipelines', + values: { + nodeName: data.nodeSummary ? data.nodeSummary.name : '', + }, + }); + + const pageTitle = i18n.translate('xpack.monitoring.logstash.node.pipelines.pageTitle', { + defaultMessage: 'Logstash node pipelines: {nodeName}', + values: { + nodeName: data.nodeSummary ? data.nodeSummary.name : '', + }, + }); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const options: any = getPaginationRouteOptions(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/node/${match.params.uuid}/pipelines`; + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + pagination: options.pagination, + queryText: options.queryText, + }), + }); + + setData(response); + updateTotalItemCount(response.totalPipelineCount); + }, [ + ccs, + clusterUuid, + services.data?.query.timefilter.timefilter, + services.http, + getPaginationRouteOptions, + updateTotalItemCount, + match.params.uuid, + ]); + + return ( + <LogstashTemplate + instance={data} + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + cluster={cluster} + > + {data.pipelines && ( + <PipelineListing + className="monitoringLogstashPipelinesTable" + onBrush={onBrush} + zoomInfo={zoomInfo} + stats={data.nodeSummary} + data={data.pipelines} + statusComponent={DetailStatus} + {...getPaginationTableProps()} + upgradeMessage={makeUpgradeMessage(data.nodeSummary.version)} + /> + )} + </LogstashTemplate> + ); +}; + +function makeUpgradeMessage(logstashVersions: any) { + if ( + !Array.isArray(logstashVersions) || + logstashVersions.length === 0 || + logstashVersions.some(isPipelineMonitoringSupportedInVersion) + ) { + return null; + } + + return 'Pipeline monitoring is only available in Logstash version 6.0.0 or higher.'; +} diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx new file mode 100644 index 0000000000000..633e47339f467 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx @@ -0,0 +1,89 @@ +/* + * 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, { useContext, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +import { ComponentProps } from '../../route_init'; +// @ts-ignore +import { Listing } from '../../../components/logstash/listing'; +import { LogstashTemplate } from './logstash_template'; +import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; +import { useTable } from '../../hooks/use_table'; + +interface SetupModeProps { + setupMode: any; + flyoutComponent: any; + bottomBarComponent: any; +} + +export const LogStashNodesPage: React.FC<ComponentProps> = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const clusterUuid = globalState.cluster_uuid; + const ccs = globalState.ccs; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }); + const [data, setData] = useState({} as any); + const { getPaginationTableProps } = useTable('logstash.nodes'); + + const title = i18n.translate('xpack.monitoring.logstash.nodes.routeTitle', { + defaultMessage: 'Logstash - Nodes', + }); + + const pageTitle = i18n.translate('xpack.monitoring.logstash.nodes.pageTitle', { + defaultMessage: 'Logstash nodes', + }); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/nodes`; + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); + + return ( + <LogstashTemplate + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + cluster={cluster} + > + <div> + <SetupModeRenderer + productName="logstash" + render={({ setupMode, flyoutComponent, bottomBarComponent }: SetupModeProps) => ( + <SetupModeContext.Provider value={{ setupModeSupported: true }}> + {flyoutComponent} + <Listing + stats={data.clusterStatus} + metrics={data.metrics} + data={data.nodes} + setupMode={setupMode} + {...getPaginationTableProps()} + /> + {bottomBarComponent} + </SetupModeContext.Provider> + )} + /> + </div> + </LogstashTemplate> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx new file mode 100644 index 0000000000000..1edbe5cf71e7d --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/overview.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, { useContext, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +import { ComponentProps } from '../../route_init'; +import { useCharts } from '../../hooks/use_charts'; +// @ts-ignore +import { Overview } from '../../../components/logstash/overview'; +import { LogstashTemplate } from './logstash_template'; + +export const LogStashOverviewPage: React.FC<ComponentProps> = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const { zoomInfo, onBrush } = useCharts(); + const { services } = useKibana<{ data: any }>(); + const clusterUuid = globalState.cluster_uuid; + const ccs = globalState.ccs; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }); + const [data, setData] = useState(null); + // const [showShardActivityHistory, setShowShardActivityHistory] = useState(false); + + const title = i18n.translate('xpack.monitoring.logstash.overview.title', { + defaultMessage: 'Logstash', + }); + + const pageTitle = i18n.translate('xpack.monitoring.logstash.overview.pageTitle', { + defaultMessage: 'Logstash overview', + }); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash`; + + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); + + const renderOverview = (overviewData: any) => { + if (overviewData === null) { + return null; + } + const { clusterStatus, metrics, logs } = overviewData || {}; + + return ( + <Overview + stats={clusterStatus} + metrics={metrics} + logs={logs} + onBrush={onBrush} + zoomInfo={zoomInfo} + /> + ); + }; + + return ( + <LogstashTemplate + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + data-test-subj="elasticsearchOverviewPage" + cluster={cluster} + > + <div data-test-subj="elasticsearchOverviewPage">{renderOverview(data)}</div> + </LogstashTemplate> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx new file mode 100644 index 0000000000000..abff0ab17b992 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx @@ -0,0 +1,175 @@ +/* + * 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, { useContext, useState, useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import moment from 'moment'; +import { useRouteMatch } from 'react-router-dom'; +import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +import { ComponentProps } from '../../route_init'; +// @ts-ignore +import { List } from '../../../components/logstash/pipeline_viewer/models/list'; +// @ts-ignore +import { PipelineViewer } from '../../../components/logstash/pipeline_viewer'; +// @ts-ignore +import { Pipeline } from '../../../components/logstash/pipeline_viewer/models/pipeline'; +// @ts-ignore +import { PipelineState } from '../../../components/logstash/pipeline_viewer/models/pipeline_state'; +// @ts-ignore +import { vertexFactory } from '../../../components/logstash/pipeline_viewer/models/graph/vertex_factory'; +import { LogstashTemplate } from './logstash_template'; +import { useTable } from '../../hooks/use_table'; +import { ExternalConfigContext } from '../../external_config_context'; +import { formatTimestampToDuration } from '../../../../common'; +import { CALCULATE_DURATION_SINCE } from '../../../../common/constants'; +import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; +import { PipelineVersions } from './pipeline_versions_dropdown'; + +export const LogStashPipelinePage: React.FC<ComponentProps> = ({ clusters }) => { + const match = useRouteMatch<{ id: string | undefined; hash: string | undefined }>(); + const { hash: pipelineHash, id: pipelineId } = match.params; + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const clusterUuid = globalState.cluster_uuid; + const { minIntervalSeconds } = useContext(ExternalConfigContext); + + const dateFormat = useUiSetting<string>('dateFormat'); + const [pipelineState, setPipelineState] = useState<PipelineState | null>(null); + const ccs = globalState.ccs; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }); + const [data, setData] = useState({} as any); + const [detailVertexId, setDetailVertexId] = useState<string | null>(null); + const { updateTotalItemCount } = useTable('logstash.pipelines'); + + const title = i18n.translate('xpack.monitoring.logstash.pipeline.routeTitle', { + defaultMessage: 'Logstash - Pipeline', + }); + + const pageTitle = i18n.translate('xpack.monitoring.logstash.pipeline.pageTitle', { + defaultMessage: 'Logstash pipeline: {pipeline}', + values: { + pipeline: data.pipeline ? data.pipeline.id : '', + }, + }); + + const getPageData = useCallback(async () => { + const url = pipelineHash + ? `../api/monitoring/v1/clusters/${clusterUuid}/logstash/pipeline/${pipelineId}/${pipelineHash}` + : `../api/monitoring/v1/clusters/${clusterUuid}/logstash/pipeline/${pipelineId}`; + + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + detailVertexId: detailVertexId || undefined, + }), + }); + const myData = response; + + myData.versions = myData.versions.map((version: any) => { + const relativeFirstSeen = formatTimestampToDuration( + version.firstSeen, + CALCULATE_DURATION_SINCE + ); + const relativeLastSeen = formatTimestampToDuration( + version.lastSeen, + CALCULATE_DURATION_SINCE + ); + + const fudgeFactorSeconds = 2 * minIntervalSeconds; + const isLastSeenCloseToNow = Date.now() - version.lastSeen <= fudgeFactorSeconds * 1000; + + return { + ...version, + relativeFirstSeen: i18n.translate( + 'xpack.monitoring.logstash.pipeline.relativeFirstSeenAgoLabel', + { + defaultMessage: '{relativeFirstSeen} ago', + values: { relativeFirstSeen }, + } + ), + relativeLastSeen: isLastSeenCloseToNow + ? i18n.translate('xpack.monitoring.logstash.pipeline.relativeLastSeenNowLabel', { + defaultMessage: 'now', + }) + : i18n.translate('xpack.monitoring.logstash.pipeline.relativeLastSeenAgoLabel', { + defaultMessage: 'until {relativeLastSeen} ago', + values: { relativeLastSeen }, + }), + }; + }); + setData(myData); + updateTotalItemCount(response.totalNodeCount); + }, [ + ccs, + clusterUuid, + services.http, + updateTotalItemCount, + detailVertexId, + minIntervalSeconds, + pipelineHash, + pipelineId, + ]); + + useEffect(() => { + if (data.pipeline) { + setPipelineState(new PipelineState(data.pipeline)); + } + }, [data]); + + const timeseriesTooltipXValueFormatter = (xValue: any) => moment(xValue).format(dateFormat); + + const onVertexChange = useCallback( + (vertex: any) => { + if (!vertex) { + setDetailVertexId(null); + } else { + setDetailVertexId(vertex.id); + } + + getPageData(); + }, + [getPageData] + ); + + const onChangePipelineHash = useCallback(() => { + window.location.hash = getSafeForExternalLink( + `#/logstash/pipelines/${pipelineId}/${pipelineHash}` + ); + }, [pipelineId, pipelineHash]); + + return ( + <LogstashTemplate + tabsDisabled={true} + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + cluster={cluster} + > + <div> + <PipelineVersions + pipelineVersions={data.versions} + onChangePipelineHash={onChangePipelineHash} + pipelineHash={pipelineHash} + /> + </div> + <div> + {pipelineState && ( + <PipelineViewer + pipeline={List.fromPipeline(Pipeline.fromPipelineGraph(pipelineState.config.graph))} + timeseriesTooltipXValueFormatter={timeseriesTooltipXValueFormatter} + setDetailVertexId={onVertexChange} + detailVertex={data.vertex ? vertexFactory(null, data.vertex) : null} + /> + )} + </div> + </LogstashTemplate> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline_versions_dropdown.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline_versions_dropdown.tsx new file mode 100644 index 0000000000000..021b8ad3b6a28 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline_versions_dropdown.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +interface Props { + pipelineVersions: any[]; + pipelineHash?: string; + onChangePipelineHash: () => void; +} + +export const PipelineVersions = (props: Props) => { + const { pipelineHash, pipelineVersions, onChangePipelineHash } = props; + + return ( + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiSelect + value={pipelineHash} + options={pipelineVersions.map((option) => { + return { + text: i18n.translate( + 'xpack.monitoring.logstashNavigation.pipelineVersionDescription', + { + defaultMessage: + 'Version active {relativeLastSeen} and first seen {relativeFirstSeen}', + values: { + relativeLastSeen: option.relativeLastSeen, + relativeFirstSeen: option.relativeFirstSeen, + }, + } + ), + value: option.hash, + }; + })} + onChange={onChangePipelineHash} + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx new file mode 100644 index 0000000000000..5f4fe634177de --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx @@ -0,0 +1,117 @@ +/* + * 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, { useContext, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +import { ComponentProps } from '../../route_init'; +import { useCharts } from '../../hooks/use_charts'; +// @ts-ignore +import { isPipelineMonitoringSupportedInVersion } from '../../../lib/logstash/pipelines'; +// @ts-ignore +import { PipelineListing } from '../../../components/logstash/pipeline_listing/pipeline_listing'; +import { LogstashTemplate } from './logstash_template'; +import { useTable } from '../../hooks/use_table'; + +export const LogStashPipelinesPage: React.FC<ComponentProps> = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const { onBrush } = useCharts(); + const { services } = useKibana<{ data: any }>(); + const dateFormat = useUiSetting<string>('dateFormat'); + + const clusterUuid = globalState.cluster_uuid; + const ccs = globalState.ccs; + + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }); + const [data, setData] = useState(null); + const { getPaginationTableProps, getPaginationRouteOptions, updateTotalItemCount } = + useTable('logstash.pipelines'); + + const title = i18n.translate('xpack.monitoring.logstash.overview.title', { + defaultMessage: 'Logstash', + }); + + const pageTitle = i18n.translate('xpack.monitoring.logstash.overview.pageTitle', { + defaultMessage: 'Logstash overview', + }); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/pipelines`; + + const options: any = getPaginationRouteOptions(); + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + pagination: options.pagination, + queryText: options.queryText, + }), + }); + + setData(response); + updateTotalItemCount(response.totalPipelineCount); + }, [ + ccs, + clusterUuid, + services.data?.query.timefilter.timefilter, + services.http, + getPaginationRouteOptions, + updateTotalItemCount, + ]); + + const renderOverview = (pageData: any) => { + if (pageData === null) { + return null; + } + const { clusterStatus, pipelines } = pageData || {}; + + const upgradeMessage = pageData ? makeUpgradeMessage(clusterStatus.versions) : null; + return ( + <PipelineListing + className="monitoringLogstashPipelinesTable" + onBrush={(xaxis: any) => onBrush({ xaxis })} + stats={clusterStatus} + data={pipelines} + {...getPaginationTableProps()} + upgradeMessage={upgradeMessage} + dateFormat={dateFormat} + /> + ); + }; + + return ( + <LogstashTemplate + title={title} + pageTitle={pageTitle} + getPageData={getPageData} + data-test-subj="elasticsearchOverviewPage" + cluster={cluster} + > + <div data-test-subj="elasticsearchOverviewPage">{renderOverview(data)}</div> + </LogstashTemplate> + ); +}; + +function makeUpgradeMessage(logstashVersions: any) { + if ( + !Array.isArray(logstashVersions) || + logstashVersions.length === 0 || + logstashVersions.some(isPipelineMonitoringSupportedInVersion) + ) { + return null; + } + + return 'Pipeline monitoring is only available in Logstash version 6.0.0 or higher.'; +} diff --git a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx index 15813edffb982..6d97a0bedd25f 100644 --- a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx @@ -12,7 +12,11 @@ import { useTitle } from '../hooks/use_title'; import { MonitoringToolbar } from '../../components/shared/toolbar'; import { MonitoringTimeContainer } from '../hooks/use_monitoring_time'; import { PageLoading } from '../../components'; -import { getSetupModeState, isSetupModeFeatureEnabled } from '../setup_mode/setup_mode'; +import { + getSetupModeState, + isSetupModeFeatureEnabled, + updateSetupModeData, +} from '../setup_mode/setup_mode'; import { SetupModeFeature } from '../../../common/enums'; import { ajaxErrorHandlersProvider } from '../../lib/ajax_error_handler'; @@ -55,9 +59,13 @@ export const PageTemplate: React.FC<PageTemplateProps> = ({ }, [getPageData, currentTimerange]); const onRefresh = () => { - getPageData?.().catch((err) => { - const errorHandler = ajaxErrorHandlersProvider(); - errorHandler(err); + const requests = [getPageData?.()]; + if (isSetupModeFeatureEnabled(SetupModeFeature.MetricbeatMigration)) { + requests.push(updateSetupModeData()); + } + + Promise.allSettled(requests).then((results) => { + // TODO: handle errors }); }; diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts index 27462f07c07be..48e8ee13059c0 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts +++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts @@ -6,3 +6,9 @@ */ export const SetupModeRenderer: FunctionComponent<Props>; + +export interface SetupModeProps { + setupMode: any; + flyoutComponent: any; + bottomBarComponent: any; +} diff --git a/x-pack/plugins/monitoring/public/lib/fetch_clusters.ts b/x-pack/plugins/monitoring/public/lib/fetch_clusters.ts new file mode 100644 index 0000000000000..41cb1ab288a17 --- /dev/null +++ b/x-pack/plugins/monitoring/public/lib/fetch_clusters.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 { HttpHandler } from 'kibana/public'; +import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../common/constants'; + +interface Params { + timeRange: { min: string; max: string }; + fetch: HttpHandler; + clusterUuid?: string | null; + ccs?: boolean; + codePaths?: string[]; +} + +export function formatClusters(clusters: any) { + return clusters.map(formatCluster); +} + +export function formatCluster(cluster: any) { + if (cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID) { + cluster.cluster_name = 'Standalone Cluster'; + } + return cluster; +} + +export const fetchClusters = async ({ clusterUuid, timeRange, fetch, ccs, codePaths }: Params) => { + let url = '../api/monitoring/v1/clusters'; + if (clusterUuid) { + url += `/${clusterUuid}`; + } + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange, + codePaths, + }), + }); + + return formatClusters(response); +}; diff --git a/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx b/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx index 47417a2bbb545..21aaba99ac5dc 100644 --- a/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx +++ b/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx @@ -50,8 +50,8 @@ export function EmptySections() { <EuiFlexItem key={app.id} style={{ - border: `1px dashed ${theme.eui.euiBorderColor}`, - borderRadius: '4px', + border: `${theme.eui.euiBorderEditable}`, + borderRadius: `${theme.eui.euiBorderRadius}`, }} > <EmptySection section={app} /> diff --git a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx index abbbee9e2deaa..c64d4353e613b 100644 --- a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx @@ -84,7 +84,6 @@ export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval function onTimeChange({ start, end }: { start: string; end: string }) { updateUrl({ rangeFrom: start, rangeTo: end }); - onRefreshTimeRange(); } return ( @@ -96,7 +95,7 @@ export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval refreshInterval={refreshInterval} onRefreshChange={onRefreshChange} commonlyUsedRanges={commonlyUsedRanges} - onRefresh={onTimeChange} + onRefresh={onRefreshTimeRange} /> ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx index aabde404aa7b4..643f01d570ead 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx @@ -91,6 +91,7 @@ export function DateRangePicker({ seriesId, series }: { seriesId: number; series })} dateFormat={dateFormat.replace('ss.SSS', 'ss')} showTimeSelect + popoverPlacement="left" /> } endDateControl={ @@ -105,6 +106,7 @@ export function DateRangePicker({ seriesId, series }: { seriesId: number; series })} dateFormat={dateFormat.replace('ss.SSS', 'ss')} showTimeSelect + popoverPlacement="left" /> } /> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx new file mode 100644 index 0000000000000..10ec4075a8155 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import styled from 'styled-components'; +import { AllSeries, useTheme } from '../../../..'; +import { LayerConfig, LensAttributes } from '../configurations/lens_attributes'; +import { ReportViewType } from '../types'; +import { getLayerConfigs } from '../hooks/use_lens_attributes'; +import { LensPublicStart } from '../../../../../../lens/public'; +import { OperationTypeComponent } from '../series_editor/columns/operation_type_select'; +import { IndexPatternState } from '../hooks/use_app_index_pattern'; + +export interface ExploratoryEmbeddableProps { + reportType: ReportViewType; + attributes: AllSeries; + appendTitle?: JSX.Element; + title: string | JSX.Element; + showCalculationMethod?: boolean; +} + +export interface ExploratoryEmbeddableComponentProps extends ExploratoryEmbeddableProps { + lens: LensPublicStart; + indexPatterns: IndexPatternState; +} + +// eslint-disable-next-line import/no-default-export +export default function Embeddable({ + reportType, + attributes, + title, + appendTitle, + indexPatterns, + lens, + showCalculationMethod = false, +}: ExploratoryEmbeddableComponentProps) { + const LensComponent = lens?.EmbeddableComponent; + + const series = Object.entries(attributes)[0][1]; + + const [operationType, setOperationType] = useState(series?.operationType); + const theme = useTheme(); + + const layerConfigs: LayerConfig[] = getLayerConfigs(attributes, reportType, theme, indexPatterns); + + if (layerConfigs.length < 1) { + return null; + } + const lensAttributes = new LensAttributes(layerConfigs); + + if (!LensComponent) { + return <EuiText>No lens component</EuiText>; + } + + return ( + <Wrapper> + <EuiFlexGroup> + <EuiFlexItem> + <EuiTitle size="s"> + <h3>{title}</h3> + </EuiTitle> + </EuiFlexItem> + {showCalculationMethod && ( + <EuiFlexItem grow={false} style={{ minWidth: 150 }}> + <OperationTypeComponent + operationType={operationType} + onChange={(val) => { + setOperationType(val); + }} + /> + </EuiFlexItem> + )} + {appendTitle} + </EuiFlexGroup> + <LensComponent + id="exploratoryView" + style={{ height: '100%' }} + timeRange={series?.time} + attributes={lensAttributes.getJSON()} + onBrushEnd={({ range }) => {}} + /> + </Wrapper> + ); +} + +const Wrapper = styled.div` + height: 100%; + &&& { + > :nth-child(2) { + height: calc(100% - 56px); + } + } +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx new file mode 100644 index 0000000000000..ad84880de5eb1 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import type { ExploratoryEmbeddableProps, ExploratoryEmbeddableComponentProps } from './embeddable'; +import { ObservabilityIndexPatterns } from '../utils/observability_index_patterns'; +import { ObservabilityPublicPluginsStart } from '../../../../plugin'; +import type { IndexPatternState } from '../hooks/use_app_index_pattern'; +import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; + +const Embeddable = React.lazy(() => import('./embeddable')); + +function ExploratoryViewEmbeddable(props: ExploratoryEmbeddableComponentProps) { + return ( + <React.Suspense fallback={<EuiLoadingSpinner />}> + <Embeddable {...props} /> + </React.Suspense> + ); +} + +export function getExploratoryViewEmbeddable( + core: CoreStart, + plugins: ObservabilityPublicPluginsStart +) { + return (props: ExploratoryEmbeddableProps) => { + const [indexPatterns, setIndexPatterns] = useState<IndexPatternState>({} as IndexPatternState); + const [loading, setLoading] = useState(false); + + const series = props.attributes[0]; + + const isDarkMode = core.uiSettings.get('theme:darkMode'); + + const loadIndexPattern = useCallback(async ({ dataType }) => { + setLoading(true); + try { + const obsvIndexP = new ObservabilityIndexPatterns(plugins.data); + const indPattern = await obsvIndexP.getIndexPattern(dataType, 'heartbeat-*'); + setIndexPatterns((prevState) => ({ ...(prevState ?? {}), [dataType]: indPattern })); + + setLoading(false); + } catch (e) { + setLoading(false); + } + }, []); + + useEffect(() => { + loadIndexPattern({ dataType: series.dataType }); + }, [series.dataType, loadIndexPattern]); + + if (Object.keys(indexPatterns).length === 0 || loading) { + return <EuiLoadingSpinner />; + } + + return ( + <EuiThemeProvider darkMode={isDarkMode}> + <ExploratoryViewEmbeddable {...props} indexPatterns={indexPatterns} lens={plugins.lens} /> + </EuiThemeProvider> + ); + }; +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index 8f061fcbfbf26..87ae00b3c72e7 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -51,9 +51,7 @@ describe('ExploratoryView', () => { expect(await screen.findByText(/No series found. Please add a series./i)).toBeInTheDocument(); expect(await screen.findByText(/Hide chart/i)).toBeInTheDocument(); expect(await screen.findByText(/Refresh/i)).toBeInTheDocument(); - expect( - await screen.findByRole('heading', { name: /Performance Distribution/i }) - ).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: /Explore data/i })).toBeInTheDocument(); }); it('renders lens component when there is series', async () => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index bec8673f88b4e..59b146ae9af1a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBetaBadge, EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { TypedLensByValueInput } from '../../../../../../lens/public'; -import { DataViewLabels } from '../configurations/constants'; import { useSeriesStorage } from '../hooks/use_series_storage'; import { LastUpdated } from './last_updated'; import { combineTimeRanges } from '../lens_embeddable'; @@ -35,10 +34,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes, lastUpdated }: <EuiFlexItem> <EuiText> <h2> - {DataViewLabels[reportType] ?? - i18n.translate('xpack.observability.expView.heading.label', { - defaultMessage: 'Analyze data', - })}{' '} + {i18n.translate('xpack.observability.expView.heading.label', { + defaultMessage: 'Explore data', + })}{' '} <EuiBetaBadge style={{ verticalAlign: `middle`, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index ef974d54e6cdc..fe00141e450cf 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -17,10 +17,11 @@ import { } from './use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; -import { SeriesUrl, UrlFilter } from '../types'; -import { useAppIndexPatternContext } from './use_app_index_pattern'; +import { ReportViewType, SeriesUrl, UrlFilter } from '../types'; +import { IndexPatternState, useAppIndexPatternContext } from './use_app_index_pattern'; import { ALL_VALUES_SELECTED } from '../../field_value_suggestions/field_value_combobox'; import { useTheme } from '../../../../hooks/use_theme'; +import { EuiTheme } from '../../../../../../../../src/plugins/kibana_react/common'; export const getFiltersFromDefs = (reportDefinitions: SeriesUrl['reportDefinitions']) => { return Object.entries(reportDefinitions ?? {}) @@ -33,6 +34,54 @@ export const getFiltersFromDefs = (reportDefinitions: SeriesUrl['reportDefinitio .filter(({ values }) => !values.includes(ALL_VALUES_SELECTED)) as UrlFilter[]; }; +export function getLayerConfigs( + allSeries: AllSeries, + reportType: ReportViewType, + theme: EuiTheme, + indexPatterns: IndexPatternState +) { + const layerConfigs: LayerConfig[] = []; + + allSeries.forEach((series, seriesIndex) => { + const indexPattern = indexPatterns?.[series?.dataType]; + + if ( + indexPattern && + !isEmpty(series.reportDefinitions) && + !series.hidden && + series.selectedMetricField + ) { + const seriesConfig = getDefaultConfigs({ + reportType, + indexPattern, + dataType: series.dataType, + }); + + const filters: UrlFilter[] = (series.filters ?? []).concat( + getFiltersFromDefs(series.reportDefinitions) + ); + + const color = `euiColorVis${seriesIndex}`; + + layerConfigs.push({ + filters, + indexPattern, + seriesConfig, + time: series.time, + name: series.name, + breakdown: series.breakdown, + seriesType: series.seriesType, + operationType: series.operationType, + reportDefinitions: series.reportDefinitions ?? {}, + selectedMetricField: series.selectedMetricField, + color: series.color ?? (theme.eui as unknown as Record<string, string>)[color], + }); + } + }); + + return layerConfigs; +} + export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null => { const { storage, allSeries, lastRefresh, reportType } = useSeriesStorage(); @@ -47,44 +96,7 @@ export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null const allSeriesT: AllSeries = convertAllShortSeries(storage.get(allSeriesKey) ?? []); - const layerConfigs: LayerConfig[] = []; - - allSeriesT.forEach((series, seriesIndex) => { - const indexPattern = indexPatterns?.[series?.dataType]; - - if ( - indexPattern && - !isEmpty(series.reportDefinitions) && - !series.hidden && - series.selectedMetricField - ) { - const seriesConfig = getDefaultConfigs({ - reportType, - indexPattern, - dataType: series.dataType, - }); - - const filters: UrlFilter[] = (series.filters ?? []).concat( - getFiltersFromDefs(series.reportDefinitions) - ); - - const color = `euiColorVis${seriesIndex}`; - - layerConfigs.push({ - filters, - indexPattern, - seriesConfig, - time: series.time, - name: series.name, - breakdown: series.breakdown, - seriesType: series.seriesType, - operationType: series.operationType, - reportDefinitions: series.reportDefinitions ?? {}, - selectedMetricField: series.selectedMetricField, - color: series.color ?? (theme.eui as unknown as Record<string, string>)[color], - }); - } - }); + const layerConfigs = getLayerConfigs(allSeriesT, reportType, theme, indexPatterns); if (layerConfigs.length < 1) { return null; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx index d9a5adc822140..85e166db94aaa 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -25,7 +25,7 @@ export interface SeriesContextValue { firstSeries?: SeriesUrl; lastRefresh: number; setLastRefresh: (val: number) => void; - applyChanges: () => void; + applyChanges: (onApply?: () => void) => void; allSeries: AllSeries; setSeries: (seriesIndex: number, newValue: SeriesUrl) => void; getSeries: (seriesIndex: number) => SeriesUrl | undefined; @@ -103,12 +103,18 @@ export function UrlStorageContextProvider({ [allSeries] ); - const applyChanges = useCallback(() => { - const allShortSeries = allSeries.map((series) => convertToShortUrl(series)); + const applyChanges = useCallback( + (onApply?: () => void) => { + const allShortSeries = allSeries.map((series) => convertToShortUrl(series)); - (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); - setLastRefresh(Date.now()); - }, [allSeries, storage]); + (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); + setLastRefresh(Date.now()); + if (onApply) { + onApply(); + } + }, + [allSeries, storage] + ); const value = { applyChanges, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx index 4c10c9311704d..6d83e25cc96e3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx @@ -36,6 +36,24 @@ export function OperationTypeSelect({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultOperationType]); + return ( + <OperationTypeComponent + onChange={onChange} + showLabel={true} + operationType={operationType || defaultOperationType} + /> + ); +} + +export function OperationTypeComponent({ + operationType, + onChange, + showLabel = false, +}: { + operationType?: OperationType; + onChange: (value: OperationType) => void; + showLabel?: boolean; +}) { const options = [ { value: 'average' as OperationType, @@ -83,9 +101,17 @@ export function OperationTypeSelect({ return ( <EuiSuperSelect + compressed fullWidth + prepend={ + showLabel + ? i18n.translate('xpack.observability.expView.operationType.label', { + defaultMessage: 'Calculation', + }) + : undefined + } data-test-subj="operationTypeSelect" - valueOfSelected={operationType || defaultOperationType} + valueOfSelected={operationType} options={options} onChange={onChange} /> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx index ea47ccd0b0426..11f96afe7ceab 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx @@ -7,6 +7,7 @@ import React from 'react'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; import { EuiFlexItem, EuiFlexGroup, EuiPanel, EuiAccordion, EuiSpacer } from '@elastic/eui'; import { BuilderItem } from '../types'; import { SeriesActions } from './columns/series_actions'; @@ -27,6 +28,10 @@ const StyledAccordion = styled(EuiAccordion)` flex-grow: 1; flex-shrink: 1; } + + .euiAccordion__childWrapper { + overflow: visible; + } `; interface Props { @@ -47,6 +52,7 @@ export function Series({ item, isExpanded, toggleExpanded }: Props) { <StyledAccordion id={`exploratoryViewSeriesAccordion${id}`} forceState={isExpanded ? 'open' : 'closed'} + aria-label={ACCORDION_LABEL} onToggle={toggleExpanded} arrowDisplay={!seriesProps.series.dataType ? 'none' : undefined} extraAction={ @@ -91,3 +97,10 @@ export function Series({ item, isExpanded, toggleExpanded }: Props) { </EuiPanel> ); } + +export const ACCORDION_LABEL = i18n.translate( + 'xpack.observability.expView.seriesBuilder.accordion.label', + { + defaultMessage: 'Toggle series information', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index d13857b5e9663..afb8baac0eaf3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -146,7 +146,7 @@ export const SeriesEditor = React.memo(function () { </EuiFlexItem> )} <EuiFlexItem> - <ViewActions /> + <ViewActions onApply={() => setItemIdToExpandedRowMap({})} /> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/views/view_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/view_actions.tsx index f4416ef60441d..ee2668aa0c39a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/views/view_actions.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/view_actions.tsx @@ -11,7 +11,11 @@ import { i18n } from '@kbn/i18n'; import { isEqual } from 'lodash'; import { allSeriesKey, convertAllShortSeries, useSeriesStorage } from '../hooks/use_series_storage'; -export function ViewActions() { +interface Props { + onApply?: () => void; +} + +export function ViewActions({ onApply }: Props) { const { allSeries, storage, applyChanges } = useSeriesStorage(); const noChanges = isEqual(allSeries, convertAllShortSeries(storage.get(allSeriesKey) ?? [])); @@ -19,7 +23,7 @@ export function ViewActions() { return ( <EuiFlexGroup justifyContent="flexEnd" alignItems="center"> <EuiFlexItem grow={false}> - <EuiButton onClick={() => applyChanges()} isDisabled={noChanges} fill size="s"> + <EuiButton onClick={() => applyChanges(onApply)} isDisabled={noChanges} fill size="s"> {i18n.translate('xpack.observability.expView.seriesBuilder.apply', { defaultMessage: 'Apply changes', })} diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 710bed3adb890..99ce1effef4b4 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -71,6 +71,7 @@ export { getApmTraceUrl } from './utils/get_apm_trace_url'; export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; export { ALL_VALUES_SELECTED } from './components/shared/field_value_suggestions/field_value_combobox'; export { FilterValueLabel } from './components/shared/filter_value_label/filter_value_label'; +export type { AllSeries } from './components/shared/exploratory_view/hooks/use_series_storage'; export type { SeriesUrl } from './components/shared/exploratory_view/types'; export type { @@ -79,3 +80,4 @@ export type { ObservabilityRuleTypeRegistry, } from './rules/create_observability_rule_type_registry'; export { createObservabilityRuleTypeRegistryMock } from './rules/observability_rule_type_registry_mock'; +export type { ExploratoryEmbeddableProps } from './components/shared/exploratory_view/embeddable/embeddable'; diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx index 3b588c59260d1..ace01aa851ce8 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx @@ -36,6 +36,7 @@ import { EuiFlexItem, EuiContextMenuPanel, EuiPopover, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -248,40 +249,63 @@ function ObservabilityActions({ ]; }, [afterCaseSelection, casePermissions, timelines, event, statusActionItems, alertPermissions]); + const viewDetailsTextLabel = i18n.translate( + 'xpack.observability.alertsTable.viewDetailsTextLabel', + { + defaultMessage: 'View details', + } + ); + const viewInAppTextLabel = i18n.translate('xpack.observability.alertsTable.viewInAppTextLabel', { + defaultMessage: 'View in app', + }); + const moreActionsTextLabel = i18n.translate( + 'xpack.observability.alertsTable.moreActionsTextLabel', + { + defaultMessage: 'More actions', + } + ); + return ( <> <EuiFlexGroup gutterSize="none" responsive={false}> <EuiFlexItem> - <EuiButtonIcon - size="s" - iconType="expand" - color="text" - onClick={() => setFlyoutAlert(alert)} - data-test-subj="openFlyoutButton" - /> + <EuiToolTip content={viewDetailsTextLabel}> + <EuiButtonIcon + size="s" + iconType="expand" + color="text" + onClick={() => setFlyoutAlert(alert)} + data-test-subj="openFlyoutButton" + aria-label={viewDetailsTextLabel} + /> + </EuiToolTip> </EuiFlexItem> <EuiFlexItem> - <EuiButtonIcon - size="s" - href={prepend(alert.link ?? '')} - iconType="eye" - color="text" - aria-label="View alert in app" - /> + <EuiToolTip content={viewInAppTextLabel}> + <EuiButtonIcon + size="s" + href={prepend(alert.link ?? '')} + iconType="eye" + color="text" + aria-label={viewInAppTextLabel} + /> + </EuiToolTip> </EuiFlexItem> {actionsMenuItems.length > 0 && ( <EuiFlexItem> <EuiPopover button={ - <EuiButtonIcon - display="empty" - size="s" - color="text" - iconType="boxesHorizontal" - aria-label="More" - onClick={() => toggleActionsPopover(eventId)} - data-test-subj="alerts-table-row-action-more" - /> + <EuiToolTip content={moreActionsTextLabel}> + <EuiButtonIcon + display="empty" + size="s" + color="text" + iconType="boxesHorizontal" + aria-label={moreActionsTextLabel} + onClick={() => toggleActionsPopover(eventId)} + data-test-subj="alerts-table-row-action-more" + /> + </EuiToolTip> } isOpen={openActionsPopoverId === eventId} closePopover={closeActionsPopover} diff --git a/x-pack/plugins/observability/public/pages/cases/cases.stories.tsx b/x-pack/plugins/observability/public/pages/cases/cases.stories.tsx index a9c83fa650394..41fa2744397c7 100644 --- a/x-pack/plugins/observability/public/pages/cases/cases.stories.tsx +++ b/x-pack/plugins/observability/public/pages/cases/cases.stories.tsx @@ -5,11 +5,13 @@ * 2.0. */ -import { EuiPageTemplate } from '@elastic/eui'; import React, { ComponentType } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { CoreStart } from '../../../../../../src/core/public'; -import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; +import { + createKibanaReactContext, + KibanaPageTemplate, +} from '../../../../../../src/plugins/kibana_react/public'; import { casesFeatureId } from '../../../common'; import { PluginContext, PluginContextValue } from '../../context/plugin_context'; import { AllCasesPage } from './all_cases'; @@ -34,7 +36,7 @@ export default { } as unknown as Partial<CoreStart>); const pluginContextValue = { - ObservabilityPageTemplate: EuiPageTemplate, + ObservabilityPageTemplate: KibanaPageTemplate, } as unknown as PluginContextValue; return ( diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 10843bbd1d5b5..e4caa8ca91944 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -43,6 +43,8 @@ import { createObservabilityRuleTypeRegistry } from './rules/create_observabilit import { createCallObservabilityApi } from './services/call_observability_api'; import { createNavigationRegistry, NavigationEntry } from './services/navigation_registry'; import { updateGlobalNavigation } from './update_global_navigation'; +import { getExploratoryViewEmbeddable } from './components/shared/exploratory_view/embeddable'; +import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; export type ObservabilityPublicSetup = ReturnType<Plugin['setup']>; @@ -233,7 +235,9 @@ export class Plugin }; } - public start({ application }: CoreStart) { + public start(coreStart: CoreStart, pluginsStart: ObservabilityPublicPluginsStart) { + const { application } = coreStart; + const config = this.initializerContext.config.get(); updateGlobalNavigation({ @@ -254,6 +258,8 @@ export class Plugin navigation: { PageTemplate, }, + createExploratoryViewUrl, + ExploratoryViewEmbeddable: getExploratoryViewEmbeddable(coreStart, pluginsStart), }; } } diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 9224a23fcb33f..3fb02677dd981 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -7,6 +7,8 @@ export const PLUGIN_ID = 'reporting'; +export const REPORTING_SYSTEM_INDEX = '.reporting'; + export const JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY = 'xpack.reporting.jobCompletionNotifications'; diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts index 62936fb2f14f3..f8426fd24852c 100644 --- a/x-pack/plugins/reporting/server/config/index.test.ts +++ b/x-pack/plugins/reporting/server/config/index.test.ts @@ -7,9 +7,12 @@ import { config } from './index'; import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; +import { configDeprecationsMock } from '../../../../../src/core/server/mocks'; const CONFIG_PATH = 'xpack.reporting'; +const deprecationContext = configDeprecationsMock.createContext(); + const applyReportingDeprecations = (settings: Record<string, any> = {}) => { const deprecations = config.deprecations!(configDeprecationFactory); const deprecationMessages: string[] = []; @@ -20,6 +23,7 @@ const applyReportingDeprecations = (settings: Record<string, any> = {}) => { deprecations.map((deprecation) => ({ deprecation, path: CONFIG_PATH, + context: deprecationContext, })), () => ({ message }) => diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts index 0998a80103131..0b2e2cac6ff7c 100644 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -84,7 +84,6 @@ describe('Reporting Config Schema', () => { }, "enabled": true, "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", "kibanaServer": Object {}, "poll": Object { "jobCompletionNotifier": Object { @@ -189,7 +188,6 @@ describe('Reporting Config Schema', () => { "useByteOrderMarkEncoding": false, }, "enabled": true, - "index": ".reporting", "kibanaServer": Object {}, "poll": Object { "jobCompletionNotifier": Object { diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index d616a18289df0..affd8b7bee7ff 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -155,8 +155,6 @@ const RolesSchema = schema.object({ allow: schema.arrayOf(schema.string(), { defaultValue: ['reporting_user'] }), }); -const IndexSchema = schema.string({ defaultValue: '.reporting' }); - // Browser side polling: job completion notifier, management table auto-refresh // NOTE: can not use schema.duration, a bug prevents it being passed to the browser correctly const PollSchema = schema.object({ @@ -178,7 +176,6 @@ export const ConfigSchema = schema.object({ csv: CsvSchema, encryptionKey: EncryptionKeySchema, roles: RolesSchema, - index: IndexSchema, poll: PollSchema, }); diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts index 5032eaab46e84..e5d0ed2613719 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts @@ -77,7 +77,6 @@ describe('CSV Execute Job', function () { stream = { write: jest.fn((chunk) => (content += chunk)) } as unknown as typeof stream; configGetStub = sinon.stub(); configGetStub.withArgs('queue', 'timeout').returns(moment.duration('2m')); - configGetStub.withArgs('index').returns('.reporting-foo-test'); configGetStub.withArgs('encryptionKey').returns(encryptionKey); configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB configGetStub.withArgs('csv', 'scroll').returns({}); diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index d49337391ca40..01a6f7a3cd06d 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -9,7 +9,7 @@ import { IndexResponse, UpdateResponse } from '@elastic/elasticsearch/api/types' import { ElasticsearchClient } from 'src/core/server'; import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; -import { ILM_POLICY_NAME } from '../../../common/constants'; +import { ILM_POLICY_NAME, REPORTING_SYSTEM_INDEX } from '../../../common/constants'; import { JobStatus, ReportOutput, ReportSource } from '../../../common/types'; import { ReportTaskParams } from '../tasks'; import { Report, ReportDocument, SavedReport } from './'; @@ -87,7 +87,7 @@ export class ReportingStore { constructor(private reportingCore: ReportingCore, private logger: LevelLogger) { const config = reportingCore.getConfig(); - this.indexPrefix = config.get('index'); + this.indexPrefix = REPORTING_SYSTEM_INDEX; this.indexInterval = config.get('queue', 'indexInterval'); this.logger = logger.clone(['store']); } diff --git a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts index afa83ed331672..54efe0636536a 100644 --- a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts @@ -13,6 +13,7 @@ import { UnwrapPromise } from '@kbn/utility-types'; import { ElasticsearchClient } from 'src/core/server'; import { PromiseType } from 'utility-types'; import { ReportingCore } from '../../'; +import { REPORTING_SYSTEM_INDEX } from '../../../common/constants'; import { ReportApiJSON, ReportSource } from '../../../common/types'; import { statuses } from '../../lib/statuses'; import { Report } from '../../lib/store'; @@ -54,9 +55,7 @@ interface JobsQueryFactory { export function jobsQueryFactory(reportingCore: ReportingCore): JobsQueryFactory { function getIndex() { - const config = reportingCore.getConfig(); - - return `${config.get('index')}-*`; + return `${REPORTING_SYSTEM_INDEX}-*`; } async function execQuery< diff --git a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts index 9a452943ff699..69213d8f8cacc 100644 --- a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts @@ -8,6 +8,7 @@ import type { estypes } from '@elastic/elasticsearch'; import type { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import type { ReportingConfig } from '../'; +import { REPORTING_SYSTEM_INDEX } from '../../common/constants'; import type { ExportTypesRegistry } from '../lib/export_types_registry'; import type { GetLicense } from './'; import { getExportStats } from './get_export_stats'; @@ -144,7 +145,7 @@ export async function getReportingUsage( esClient: ElasticsearchClient, exportTypesRegistry: ExportTypesRegistry ): Promise<ReportingUsageType> { - const reportingIndex = config.get('index'); + const reportingIndex = REPORTING_SYSTEM_INDEX; const params = { index: `${reportingIndex}-*`, filterPath: 'aggregations.*.buckets', diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts index 54a4b80a35bb4..1c59e56c0466a 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts @@ -16,6 +16,7 @@ export const technicalRuleFieldMap = { Fields.EVENT_ACTION, Fields.TAGS ), + [Fields.ALERT_RULE_PARAMS]: { type: 'keyword', index: false }, [Fields.ALERT_RULE_TYPE_ID]: { type: 'keyword', required: true }, [Fields.ALERT_RULE_CONSUMER]: { type: 'keyword', required: true }, [Fields.ALERT_RULE_PRODUCER]: { type: 'keyword', required: true }, diff --git a/x-pack/plugins/security/common/model/deprecations.ts b/x-pack/plugins/security/common/model/deprecations.ts new file mode 100644 index 0000000000000..e990f370c5173 --- /dev/null +++ b/x-pack/plugins/security/common/model/deprecations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { DeprecationsDetails, GetDeprecationsContext } from '../../../../../src/core/server'; +import type { Role } from './role'; + +export interface PrivilegeDeprecationsRolesByFeatureIdResponse { + roles?: Role[]; + errors?: DeprecationsDetails[]; +} + +export interface PrivilegeDeprecationsRolesByFeatureIdRequest { + context: GetDeprecationsContext; + featureId: string; +} +export interface PrivilegeDeprecationsService { + getKibanaRolesByFeatureId: ( + args: PrivilegeDeprecationsRolesByFeatureIdRequest + ) => Promise<PrivilegeDeprecationsRolesByFeatureIdResponse>; +} diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 8eb341ef9bd37..082e6bdc12cd0 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -33,3 +33,8 @@ export { RoleTemplate, RoleMapping, } from './role_mapping'; +export { + PrivilegeDeprecationsRolesByFeatureIdRequest, + PrivilegeDeprecationsRolesByFeatureIdResponse, + PrivilegeDeprecationsService, +} from './deprecations'; diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts index 4d67f3435e7da..221baa85a65f6 100644 --- a/x-pack/plugins/security/server/authorization/index.ts +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -13,3 +13,4 @@ export { } from './authorization_service'; export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; export { CheckPrivilegesPayload } from './types'; +export { transformElasticsearchRoleToRole, ElasticsearchRole } from './roles'; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts similarity index 96% rename from x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts rename to x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts index fa119ca704753..c0dab16f97af8 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts @@ -8,10 +8,10 @@ import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD, -} from '../../../../../common/constants'; -import type { Role, RoleKibanaPrivilege } from '../../../../../common/model'; -import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer'; -import { ResourceSerializer } from '../../../../authorization/resource_serializer'; +} from '../../../common/constants'; +import type { Role, RoleKibanaPrivilege } from '../../../common/model'; +import { PrivilegeSerializer } from '../privilege_serializer'; +import { ResourceSerializer } from '../resource_serializer'; export type ElasticsearchRole = Pick<Role, 'name' | 'metadata' | 'transient_metadata'> & { applications: Array<{ diff --git a/x-pack/plugins/security/server/authorization/roles/index.ts b/x-pack/plugins/security/server/authorization/roles/index.ts new file mode 100644 index 0000000000000..a5047a1872c09 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/roles/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 { transformElasticsearchRoleToRole, ElasticsearchRole } from './elasticsearch_role'; diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 3be565d59a11f..98f11d56853b2 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -1729,7 +1729,7 @@ describe('createConfig()', () => { }, }, }) - ).toThrow('[audit.appender.2.type]: expected value to equal [legacy-appender]'); + ).toThrow('[audit.appender.1.layout]: expected at least one defined value but got [undefined]'); }); it('rejects an ignore_filter when no appender is configured', () => { diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts index 18f864012cb87..beac88293026c 100644 --- a/x-pack/plugins/security/server/config_deprecations.test.ts +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -9,8 +9,11 @@ import { cloneDeep } from 'lodash'; import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; +import { configDeprecationsMock } from '../../../../src/core/server/mocks'; import { securityConfigDeprecationProvider } from './config_deprecations'; +const deprecationContext = configDeprecationsMock.createContext(); + const applyConfigDeprecations = (settings: Record<string, any> = {}) => { const deprecations = securityConfigDeprecationProvider(configDeprecationFactory); const deprecationMessages: string[] = []; @@ -19,6 +22,7 @@ const applyConfigDeprecations = (settings: Record<string, any> = {}) => { deprecations.map((deprecation) => ({ deprecation, path: 'xpack.security', + context: deprecationContext, })), () => ({ message }) => diff --git a/x-pack/plugins/security/server/deprecations/index.ts b/x-pack/plugins/security/server/deprecations/index.ts new file mode 100644 index 0000000000000..05802a5a673c5 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/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. + */ + +/** + * getKibanaRolesByFeature + */ + +export { getPrivilegeDeprecationsService } from './privilege_deprecations'; diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts new file mode 100644 index 0000000000000..e889eb17d5af9 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts @@ -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 type { GetDeprecationsContext } from 'src/core/server'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; + +import { getPrivilegeDeprecationsService } from '.'; +import { licenseMock } from '../../common/licensing/index.mock'; + +const kibanaIndexName = '.a-kibana-index'; +const application = `kibana-${kibanaIndexName}`; + +describe('#getPrivilegeDeprecationsService', () => { + describe('#getKibanaRolesByFeatureId', () => { + const mockAsCurrentUser = elasticsearchServiceMock.createScopedClusterClient(); + const mockLicense = licenseMock.create(); + const mockLogger = loggingSystemMock.createLogger(); + const authz = { applicationName: application }; + + const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService( + authz, + mockLicense, + mockLogger + ); + + it('happy path to find siem roles with feature_siem privileges', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['feature_siem.all', 'feature_siem.cases_read'], + resources: ['space:securitySolutions'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "roles": Array [ + Object { + "_transform_error": Array [], + "_unrecognized_applications": Array [], + "elasticsearch": Object { + "cluster": Array [], + "indices": Array [], + "run_as": Array [], + }, + "kibana": Array [ + Object { + "base": Array [], + "feature": Object { + "siem": Array [ + "all", + "cases_read", + ], + }, + "spaces": Array [ + "securitySolutions", + ], + }, + ], + "metadata": Object { + "_reserved": true, + }, + "name": "first_role", + "transient_metadata": Object { + "enabled": true, + }, + }, + ], + } + `); + }); + + it('happy path to find siem roles with feature_siem and feature_foo and feature_bar privileges', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + 'feature_siem.all', + 'feature_siem.cases_read', + ], + resources: ['space:securitySolutions'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "roles": Array [ + Object { + "_transform_error": Array [], + "_unrecognized_applications": Array [], + "elasticsearch": Object { + "cluster": Array [], + "indices": Array [], + "run_as": Array [], + }, + "kibana": Array [ + Object { + "base": Array [], + "feature": Object { + "bar": Array [ + "bar-privilege-1", + ], + "foo": Array [ + "foo-privilege-1", + "foo-privilege-2", + ], + "siem": Array [ + "all", + "cases_read", + ], + }, + "spaces": Array [ + "securitySolutions", + ], + }, + ], + "metadata": Object { + "_reserved": true, + }, + "name": "first_role", + "transient_metadata": Object { + "enabled": true, + }, + }, + ], + } + `); + }); + + it('happy path to NOT find siem roles with and feature_foo and feature_bar privileges', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['space:securitySolutions'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "roles": Array [], + } + `); + }); + + it('unhappy path with status code 400, we should have the attribute errors', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createErrorTransportRequestPromise({ + message: 'Test error', + statusCode: 400, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "A user with the \\"manage_security\\" cluster privilege is required to perform this check.", + ], + }, + "level": "fetch_error", + "message": "Error retrieving roles for privilege deprecations: Test error", + "title": "Error in privilege deprecations services", + }, + ], + } + `); + }); + + it('unhappy path with status code 403, we should have unauthorized message in the attribute errors', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createErrorTransportRequestPromise({ + message: 'Test error', + statusCode: 403, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "A user with the \\"manage_security\\" cluster privilege is required to perform this check.", + ], + }, + "level": "fetch_error", + "message": "You must have the 'manage_security' cluster privilege to fix role deprecations.", + "title": "Error in privilege deprecations services", + }, + ], + } + `); + }); + }); +}); diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts new file mode 100644 index 0000000000000..df212d5c7bde3 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts @@ -0,0 +1,106 @@ +/* + * 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 type { Logger } from 'src/core/server'; + +import type { SecurityLicense } from '../../common/licensing'; +import type { + PrivilegeDeprecationsRolesByFeatureIdRequest, + PrivilegeDeprecationsRolesByFeatureIdResponse, +} from '../../common/model'; +import { transformElasticsearchRoleToRole } from '../authorization'; +import type { AuthorizationServiceSetupInternal, ElasticsearchRole } from '../authorization'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; + +export const getPrivilegeDeprecationsService = ( + authz: Pick<AuthorizationServiceSetupInternal, 'applicationName'>, + license: SecurityLicense, + logger: Logger +) => { + const getKibanaRolesByFeatureId = async ({ + context, + featureId, + }: PrivilegeDeprecationsRolesByFeatureIdRequest): Promise<PrivilegeDeprecationsRolesByFeatureIdResponse> => { + // Nothing to do if security is disabled + if (!license.isEnabled()) { + return { + roles: [], + }; + } + let kibanaRoles; + try { + const { body: elasticsearchRoles } = await context.esClient.asCurrentUser.security.getRole< + Record<string, ElasticsearchRole> + >(); + kibanaRoles = Object.entries(elasticsearchRoles).map(([roleName, elasticsearchRole]) => + transformElasticsearchRoleToRole( + // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` + elasticsearchRole, + roleName, + authz.applicationName + ) + ); + } catch (e) { + const statusCode = getErrorStatusCode(e); + const isUnauthorized = statusCode === 403; + const message = isUnauthorized + ? i18n.translate('xpack.security.privilegeDeprecationsService.error.unauthorized.message', { + defaultMessage: `You must have the 'manage_security' cluster privilege to fix role deprecations.`, + }) + : i18n.translate( + 'xpack.security.privilegeDeprecationsService.error.retrievingRoles.message', + { + defaultMessage: `Error retrieving roles for privilege deprecations: {message}`, + values: { + message: getDetailedErrorMessage(e), + }, + } + ); + + if (isUnauthorized) { + logger.warn( + `Failed to retrieve roles when checking for deprecations: the manage_security cluster privilege is required` + ); + } else { + logger.error( + `Failed to retrieve roles when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + e + )}` + ); + } + + return { + errors: [ + { + title: i18n.translate('xpack.security.privilegeDeprecationsService.error.title', { + defaultMessage: `Error in privilege deprecations services`, + }), + level: 'fetch_error', + message, + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.security.privilegeDeprecationsService.manualSteps.message', { + defaultMessage: + 'A user with the "manage_security" cluster privilege is required to perform this check.', + }), + ], + }, + }, + ], + }; + } + return { + roles: kibanaRoles.filter((role) => + role.kibana.find((privilege) => Object.hasOwnProperty.call(privilege.feature, featureId)) + ), + }; + }; + return Object.freeze({ + getKibanaRolesByFeatureId, + }); +}; diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index f1f858a40a465..7cae0d29bf943 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -28,6 +28,9 @@ function createSetupMock() { }, registerSpacesService: jest.fn(), license: licenseMock.create(), + privilegeDeprecationsService: { + getKibanaRolesByFeatureId: jest.fn(), + }, }; } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index eb88aba1c0e1b..4784e14a11fb4 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -123,6 +123,9 @@ describe('Security Plugin', () => { "isEnabled": [Function], "isLicenseAvailable": [Function], }, + "privilegeDeprecationsService": Object { + "getKibanaRolesByFeatureId": [Function], + }, } `); }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index e3da0716f29ee..2ad75a1c53174 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -30,7 +30,7 @@ import type { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; import type { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import type { SecurityLicense } from '../common/licensing'; import { SecurityLicenseService } from '../common/licensing'; -import type { AuthenticatedUser } from '../common/model'; +import type { AuthenticatedUser, PrivilegeDeprecationsService } from '../common/model'; import type { AnonymousAccessServiceStart } from './anonymous_access'; import { AnonymousAccessService } from './anonymous_access'; import type { AuditServiceSetup } from './audit'; @@ -44,6 +44,7 @@ import type { AuthorizationServiceSetup, AuthorizationServiceSetupInternal } fro import { AuthorizationService } from './authorization'; import type { ConfigSchema, ConfigType } from './config'; import { createConfig } from './config'; +import { getPrivilegeDeprecationsService } from './deprecations'; import { ElasticsearchService } from './elasticsearch'; import type { SecurityFeatureUsageServiceStart } from './feature_usage'; import { SecurityFeatureUsageService } from './feature_usage'; @@ -85,6 +86,10 @@ export interface SecurityPluginSetup { * Exposes services for audit logging. */ audit: AuditServiceSetup; + /** + * Exposes services to access kibana roles per feature id with the GetDeprecationsContext + */ + privilegeDeprecationsService: PrivilegeDeprecationsService; } /** @@ -321,9 +326,7 @@ export class SecurityPlugin asScoped: this.auditSetup.asScoped, getLogger: this.auditSetup.getLogger, }, - authc: { getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request) }, - authz: { actions: this.authorizationSetup.actions, checkPrivilegesWithRequest: this.authorizationSetup.checkPrivilegesWithRequest, @@ -333,8 +336,12 @@ export class SecurityPlugin this.authorizationSetup.checkSavedObjectsPrivilegesWithRequest, mode: this.authorizationSetup.mode, }, - license, + privilegeDeprecationsService: getPrivilegeDeprecationsService( + this.authorizationSetup, + license, + this.logger.get('deprecations') + ), }); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts index 8334dd3c05476..e090cd26dc39f 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { ElasticsearchRole, transformElasticsearchRoleToRole } from './elasticsearch_role'; +export { ElasticsearchRole, transformElasticsearchRoleToRole } from '../../../../authorization'; export { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './put_payload'; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts index 8a560d7b6dd87..7fb2baf0fd410 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts @@ -10,10 +10,10 @@ import _ from 'lodash'; import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; +import type { ElasticsearchRole } from '.'; import { GLOBAL_RESOURCE } from '../../../../../common/constants'; import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer'; import { ResourceSerializer } from '../../../../authorization/resource_serializer'; -import type { ElasticsearchRole } from './elasticsearch_role'; /** * Elasticsearch specific portion of the role definition. diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/host_isolation_exception_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/host_isolation_exception_generator.ts new file mode 100644 index 0000000000000..1790924182dfc --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/host_isolation_exception_generator.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { BaseDataGenerator } from './base_data_generator'; +import { getCreateExceptionListItemSchemaMock } from '../../../../lists/common/schemas/request/create_exception_list_item_schema.mock'; + +export class HostIsolationExceptionGenerator extends BaseDataGenerator<CreateExceptionListItemSchema> { + generate(): CreateExceptionListItemSchema { + const overrides: Partial<CreateExceptionListItemSchema> = { + name: `generator exception ${this.randomString(5)}`, + list_id: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + item_id: `generator_endpoint_host_isolation_exception_${this.randomUUID()}`, + os_types: ['windows', 'linux', 'macos'], + tags: ['policy:all'], + namespace_type: 'agnostic', + meta: undefined, + description: `Description ${this.randomString(5)}`, + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: this.randomIP(), + }, + ], + }; + + return Object.assign<CreateExceptionListItemSchema, Partial<CreateExceptionListItemSchema>>( + getCreateExceptionListItemSchemaMock(), + overrides + ); + } +} diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index a1b8ca98afc20..1492e0e8c82c9 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1663,6 +1663,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { install_status: 'installed', install_started_at: '2020-06-24T14:41:23.098Z', install_source: 'registry', + keep_policies_up_to_date: false, }, references: [], updated_at: '2020-06-24T14:41:23.098Z', diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index d70011f864860..b500091aacc2d 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -207,7 +207,7 @@ node ../../../scripts/es_archiver load auditbeat --dir ../../test/security_solut # launch the cypress test runner with overridden environment variables cd x-pack/plugins/security_solution -CYPRESS_base_url=http(s)://<username>:<password>@<kbnUrl> CYPRESS_ELASTICSEARCH_URL=http(s)://<username>:<password>@<elsUrl> CYPRESS_ELASTICSEARCH_USERNAME=<username> CYPRESS_ELASTICSEARCH_PASSWORD=<password> CYPRESS_protocol=<httpOrHttps> CYPRESS_hostname=<kibanaInstanceHostName> CYPRESS_configport=<kibanaPort> CYPRESS_KIBANA_URL=<kbnUrl> yarn cypress:run +CYPRESS_BASE_URL=http(s)://<username>:<password>@<kbnUrl> CYPRESS_ELASTICSEARCH_URL=http(s)://<username>:<password>@<elsUrl> CYPRESS_ELASTICSEARCH_USERNAME=<username> CYPRESS_ELASTICSEARCH_PASSWORD=<password> yarn cypress:run ``` #### Custom Target + Headless (Firefox) @@ -225,7 +225,7 @@ node ../../../scripts/es_archiver load auditbeat --dir ../../test/security_solut # launch the cypress test runner with overridden environment variables cd x-pack/plugins/security_solution -CYPRESS_base_url=http(s)://<username>:<password>@<kbnUrl> CYPRESS_ELASTICSEARCH_URL=http(s)://<username>:<password>@<elsUrl> CYPRESS_ELASTICSEARCH_USERNAME=<username> CYPRESS_ELASTICSEARCH_PASSWORD=<password> CYPRESS_protocol=<httpOrHttps> CYPRESS_hostname=<kibanaInstanceHostName> CYPRESS_configport=<kibanaPort> CYPRESS_KIBANA_URL=<kbnUrl> yarn cypress:run:firefox +CYPRESS_BASE_URL=http(s)://<username>:<password>@<kbnUrl> CYPRESS_ELASTICSEARCH_URL=http(s)://<username>:<password>@<elsUrl> CYPRESS_ELASTICSEARCH_USERNAME=<username> CYPRESS_ELASTICSEARCH_PASSWORD=<password> yarn cypress:run:firefox ``` #### CCS Custom Target + Headless diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index db76bfc3cf4df..788e177fec721 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -72,10 +72,6 @@ export interface OverrideRule extends CustomRule { timestampOverride: string; } -export interface EventCorrelationRule extends CustomRule { - language: string; -} - export interface ThreatIndicatorRule extends CustomRule { indicatorIndexPattern: string[]; indicatorMappingField: string; @@ -330,7 +326,7 @@ export const getEqlRule = (): CustomRule => ({ maxSignals: 100, }); -export const getCCSEqlRule = (): EventCorrelationRule => ({ +export const getCCSEqlRule = (): CustomRule => ({ customQuery: 'any where process.name == "run-parts"', name: 'New EQL Rule', index: [`${ccsRemoteName}:run-parts`], @@ -346,7 +342,6 @@ export const getCCSEqlRule = (): EventCorrelationRule => ({ lookBack: getLookBack(), timeline: getTimeline(), maxSignals: 100, - language: 'eql', }); export const getEqlSequenceRule = (): CustomRule => ({ diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index 130467cde053d..04ff0fcabc081 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CustomRule, EventCorrelationRule, ThreatIndicatorRule } from '../../objects/rule'; +import { CustomRule, ThreatIndicatorRule } from '../../objects/rule'; export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing', interval = '100m') => cy.request({ @@ -29,7 +29,7 @@ export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing', inte failOnStatusCode: false, }); -export const createEventCorrelationRule = (rule: EventCorrelationRule, ruleId = 'rule_testing') => +export const createEventCorrelationRule = (rule: CustomRule, ruleId = 'rule_testing') => cy.request({ method: 'POST', url: 'api/detection_engine/rules', diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index 243bfd113bfd2..5a935702131d6 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -56,13 +56,15 @@ const LOGIN_API_ENDPOINT = '/internal/security/login'; * @param route string route to visit */ export const getUrlWithRoute = (role: ROLES, route: string) => { + const url = Cypress.config().baseUrl; + const kibana = new URL(url!); const theUrl = `${Url.format({ auth: `${role}:changeme`, username: role, password: 'changeme', - protocol: Cypress.env('protocol'), - hostname: Cypress.env('hostname'), - port: Cypress.env('configport'), + protocol: kibana.protocol.replace(':', ''), + hostname: kibana.hostname, + port: kibana.port, } as UrlObject)}${route.startsWith('/') ? '' : '/'}${route}`; cy.log(`origin: ${theUrl}`); return theUrl; @@ -80,11 +82,13 @@ interface User { * @param route string route to visit */ export const constructUrlWithUser = (user: User, route: string) => { - const hostname = Cypress.env('hostname'); + const url = Cypress.config().baseUrl; + const kibana = new URL(url!); + const hostname = kibana.hostname; const username = user.username; const password = user.password; - const protocol = Cypress.env('protocol'); - const port = Cypress.env('configport'); + const protocol = kibana.protocol.replace(':', ''); + const port = kibana.port; const path = `${route.startsWith('/') ? '' : '/'}${route}`; const strUrl = `${protocol}://${username}:${password}@${hostname}:${port}${path}`; @@ -98,7 +102,7 @@ export const getCurlScriptEnvVars = () => ({ ELASTICSEARCH_URL: Cypress.env('ELASTICSEARCH_URL'), ELASTICSEARCH_USERNAME: Cypress.env('ELASTICSEARCH_USERNAME'), ELASTICSEARCH_PASSWORD: Cypress.env('ELASTICSEARCH_PASSWORD'), - KIBANA_URL: Cypress.env('KIBANA_URL'), + KIBANA_URL: Cypress.config().baseUrl, }); export const postRoleAndUser = (role: ROLES) => { diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index 04f81d2cac3df..b152ccd546170 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -23,6 +23,10 @@ import { } from './bottom_bar'; import { useShowTimeline } from '../../../common/utils/timeline/use_show_timeline'; import { gutterTimeline } from '../../../common/lib/helpers'; +import { useSourcererScope } from '../../../common/containers/sourcerer'; +import { OverviewEmpty } from '../../../overview/components/overview_empty'; +import { ENDPOINT_METADATA_INDEX } from '../../../../common/constants'; +import { useFetchIndex } from '../../../common/containers/source'; /* eslint-disable react/display-name */ @@ -73,11 +77,16 @@ export const SecuritySolutionTemplateWrapper: React.FC<SecuritySolutionPageWrapp const { show: isShowingTimelineOverlay } = useDeepEqualSelector((state) => getTimelineShowStatus(state, TimelineId.active) ); + const endpointMetadataIndex = useMemo<string[]>(() => { + return [ENDPOINT_METADATA_INDEX]; + }, []); + const [, { indexExists: metadataIndexExists }] = useFetchIndex(endpointMetadataIndex, true); + const { indicesExist } = useSourcererScope(); + const securityIndicesExist = indicesExist || metadataIndexExists; - /* StyledKibanaPageTemplate is a styled EuiPageTemplate. Security solution currently passes the header and page content as the children of StyledKibanaPageTemplate, as opposed to using the pageHeader prop, which may account for any style discrepancies, such as the bottom border not extending the full width of the page, between EuiPageTemplate and the security solution pages. - */ + // StyledKibanaPageTemplate is a styled EuiPageTemplate. Security solution currently passes the header and page content as the children of StyledKibanaPageTemplate, as opposed to using the pageHeader prop, which may account for any style discrepancies, such as the bottom border not extending the full width of the page, between EuiPageTemplate and the security solution pages. - return ( + return securityIndicesExist ? ( <StyledKibanaPageTemplate $isTimelineBottomBarVisible={isTimelineBottomBarVisible} $isShowingTimelineOverlay={isShowingTimelineOverlay} @@ -98,5 +107,7 @@ export const SecuritySolutionTemplateWrapper: React.FC<SecuritySolutionPageWrapp {children} </EuiPanel> </StyledKibanaPageTemplate> + ) : ( + <OverviewEmpty /> ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx b/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx index 28ff98d5c9c88..febc59e7542f5 100644 --- a/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx @@ -8,15 +8,14 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { useKibana } from '../../lib/kibana'; +import { SOLUTION_NAME } from '../../translations'; export const HelpMenu = React.memo(() => { const { chrome, docLinks } = useKibana().services; useEffect(() => { chrome.setHelpExtension({ - appName: i18n.translate('xpack.securitySolution.chrome.help.appName', { - defaultMessage: 'Security', - }), + appName: SOLUTION_NAME, links: [ { content: i18n.translate('xpack.securitySolution.chrome.helpMenu.documentation', { diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx index f70f96a26c83a..9d0f68a9bb483 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx @@ -11,16 +11,30 @@ import { EuiToolTip, EuiLink } from '@elastic/eui'; import { useTimelineClick } from '../../../../utils/timeline/use_timeline_click'; import { TimelineProps } from './types'; import * as i18n from './translations'; +import { useAppToasts } from '../../../../hooks/use_app_toasts'; export const TimelineMarkDownRendererComponent: React.FC<TimelineProps> = ({ id, title, graphEventId, }) => { + const { addError } = useAppToasts(); + const handleTimelineClick = useTimelineClick(); + + const onError = useCallback( + (error: Error, timelineId: string) => { + addError(error, { + title: i18n.TIMELINE_ERROR_TITLE, + toastMessage: i18n.FAILED_TO_RETRIEVE_TIMELINE(timelineId), + }); + }, + [addError] + ); + const onClickTimeline = useCallback( - () => handleTimelineClick(id ?? '', graphEventId), - [id, graphEventId, handleTimelineClick] + () => handleTimelineClick(id ?? '', onError, graphEventId), + [id, graphEventId, handleTimelineClick, onError] ); return ( <EuiToolTip content={i18n.TIMELINE_ID(id ?? '')}> diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts index a32f9c263be49..2c3f5e30b5b5d 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts @@ -53,3 +53,19 @@ export const NO_PARENTHESES = i18n.translate( defaultMessage: 'Expected left parentheses', } ); + +export const FAILED_TO_RETRIEVE_TIMELINE = (timelineId: string) => + i18n.translate( + 'xpack.securitySolution.markdownEditor.plugins.timeline.failedRetrieveTimelineErrorMsg', + { + defaultMessage: 'Failed to retrieve timeline id: { timelineId }', + values: { timelineId }, + } + ); + +export const TIMELINE_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.markdownEditor.plugins.timeline.timelineErrorTitle', + { + defaultMessage: 'Timeline Error', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx index 6abca9cfe8853..cf5e8a5bad80a 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx @@ -14,7 +14,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import moment from 'moment'; -import React, { Dispatch, useCallback, useReducer, useState } from 'react'; +import React, { Dispatch, useCallback, useReducer, useState, useMemo } from 'react'; import styled from 'styled-components'; import { useKibana } from '../../lib/kibana'; @@ -30,6 +30,7 @@ import * as i18n from './translations'; import { JobsFilters, SecurityJob } from './types'; import { UpgradeContents } from './upgrade_contents'; import { useSecurityJobs } from './hooks/use_security_jobs'; +import { MLJobsAwaitingNodeWarning } from '../../../../../ml/public'; const PopoverContentsDiv = styled.div` max-width: 684px; @@ -116,6 +117,10 @@ export const MlPopover = React.memo(() => { }); const incompatibleJobCount = jobs.filter((j) => !j.isCompatible).length; + const installedJobsIds = useMemo( + () => jobs.filter((j) => j.isInstalled).map((j) => j.id), + [jobs] + ); if (!isLicensed) { // If the user does not have platinum show upgrade UI @@ -216,6 +221,7 @@ export const MlPopover = React.memo(() => { </> )} + <MLJobsAwaitingNodeWarning jobIds={installedJobsIds} /> <JobsTable isLoading={isLoadingSecurityJobs || isLoading} jobs={filteredJobs} diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts index 1eefd69d57eb7..2058eaf03b5e1 100644 --- a/x-pack/plugins/security_solution/public/common/translations.ts +++ b/x-pack/plugins/security_solution/public/common/translations.ts @@ -7,8 +7,8 @@ import { i18n } from '@kbn/i18n'; -export const EMPTY_TITLE = i18n.translate('xpack.securitySolution.pages.common.emptyTitle', { - defaultMessage: 'Welcome to Elastic Security. Let’s get you started.', +export const SOLUTION_NAME = i18n.translate('xpack.securitySolution.pages.common.solutionName', { + defaultMessage: 'Security', }); export const EMPTY_ACTION_ELASTIC_AGENT = i18n.translate( diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx index 2756ba2a696e1..826ac7c32b7b0 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx @@ -11,16 +11,18 @@ import { dispatchUpdateTimeline, queryTimelineById, } from '../../../timelines/components/open_timeline/helpers'; +import { TimelineErrorCallback } from '../../../timelines/components/open_timeline/types'; import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions'; export const useTimelineClick = () => { const dispatch = useDispatch(); const handleTimelineClick = useCallback( - (timelineId: string, graphEventId?: string) => { + (timelineId: string, onError: TimelineErrorCallback, graphEventId?: string) => { queryTimelineById({ graphEventId, timelineId, + onError, updateIsLoading: ({ id: currentTimelineId, isLoading, diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/translations.tsx index 0d628d89c0925..1468f4c00d506 100644 --- a/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/translations.tsx @@ -56,7 +56,7 @@ export const missingPrivilegesCallOutBody = ({ }: MissingPrivileges) => ( <FormattedMessage id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.messageDetail" - defaultMessage="{essence} {indexPrivileges} Related documentation: {docs}" + defaultMessage="{essence} {indexPrivileges} {featurePrivileges} Related documentation: {docs}" values={{ essence: ( <p> @@ -77,30 +77,23 @@ export const missingPrivilegesCallOutBody = ({ {indexPrivileges.map(([index, missingPrivileges]) => ( <li key={index}>{missingIndexPrivileges(index, missingPrivileges)}</li> ))} - { - // TODO: Uncomment once RBAC for alerts is reenabled - /* {featurePrivileges.map(([feature, missingPrivileges]) => ( + </ul> + </> + ) : null, + featurePrivileges: + featurePrivileges.length > 0 ? ( + <> + <FormattedMessage + id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.featurePrivilegesTitle" + defaultMessage="Missing Kibana feature privileges:" + /> + <ul> + {featurePrivileges.map(([feature, missingPrivileges]) => ( <li key={feature}>{missingFeaturePrivileges(feature, missingPrivileges)}</li> - ))} */ - } + ))} </ul> </> ) : null, - // TODO: Uncomment once RBAC for alerts is reenabled - // featurePrivileges: - // featurePrivileges.length > 0 ? ( - // <> - // <FormattedMessage - // id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.featurePrivilegesTitle" - // defaultMessage="Missing Kibana feature privileges:" - // /> - // <ul> - // {featurePrivileges.map(([feature, missingPrivileges]) => ( - // <li key={feature}>{missingFeaturePrivileges(feature, missingPrivileges)}</li> - // ))} - // </ul> - // </> - // ) : null, docs: ( <ul> <li> @@ -159,15 +152,14 @@ const missingIndexPrivileges = (index: string, privileges: string[]) => ( /> ); -// TODO: Uncomment once RBAC for alerts is reenabled -// const missingFeaturePrivileges = (feature: string, privileges: string[]) => ( -// <FormattedMessage -// id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.missingFeaturePrivileges" -// defaultMessage="Missing {privileges} privileges for the {index} feature. {explanation}" -// values={{ -// privileges: <CommaSeparatedValues values={privileges} />, -// index: <EuiCode>{feature}</EuiCode>, -// explanation: getPrivilegesExplanation(privileges, feature), -// }} -// /> -// ); +const missingFeaturePrivileges = (feature: string, privileges: string[]) => ( + <FormattedMessage + id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.missingFeaturePrivileges" + defaultMessage="Missing {privileges} privileges for the {index} feature. {explanation}" + values={{ + privileges: <CommaSeparatedValues values={privileges} />, + index: <EuiCode>{feature}</EuiCode>, + explanation: getPrivilegesExplanation(privileges, feature), + }} + /> +); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts index 85545303c7df0..79ca595fbb61b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts @@ -64,3 +64,13 @@ export async function getHostIsolationExceptionItems({ }); return entries; } + +export async function deleteHostIsolationExceptionItems(http: HttpStart, id: string) { + await ensureHostIsolationExceptionsListExists(http); + return http.delete<ExceptionListItemSchema>(EXCEPTION_LIST_ITEM_URL, { + query: { + id, + namespace_type: 'agnostic', + }, + }); +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts index 793c44ce79db2..0a9f776655371 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { Action } from 'redux'; import { HostIsolationExceptionsPageState } from '../types'; @@ -13,4 +14,19 @@ export type HostIsolationExceptionsPageDataChanged = payload: HostIsolationExceptionsPageState['entries']; }; -export type HostIsolationExceptionsPageAction = HostIsolationExceptionsPageDataChanged; +export type HostIsolationExceptionsDeleteItem = Action<'hostIsolationExceptionsMarkToDelete'> & { + payload?: ExceptionListItemSchema; +}; + +export type HostIsolationExceptionsSubmitDelete = Action<'hostIsolationExceptionsSubmitDelete'>; + +export type HostIsolationExceptionsDeleteStatusChanged = + Action<'hostIsolationExceptionsDeleteStatusChanged'> & { + payload: HostIsolationExceptionsPageState['deletion']['status']; + }; + +export type HostIsolationExceptionsPageAction = + | HostIsolationExceptionsPageDataChanged + | HostIsolationExceptionsDeleteItem + | HostIsolationExceptionsSubmitDelete + | HostIsolationExceptionsDeleteStatusChanged; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts index f5ea3c27bde7f..68a50f9c813f4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts @@ -16,4 +16,8 @@ export const initialHostIsolationExceptionsPageState = (): HostIsolationExceptio page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, filter: '', }, + deletion: { + item: undefined, + status: createUninitialisedResourceState(), + }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts index cde9d89443903..984794e074ebb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts @@ -14,8 +14,12 @@ import { createSpyMiddleware, MiddlewareActionSpyHelper, } from '../../../../common/store/test_utils'; -import { isFailedResourceState, isLoadedResourceState } from '../../../state'; -import { getHostIsolationExceptionItems } from '../service'; +import { + isFailedResourceState, + isLoadedResourceState, + isLoadingResourceState, +} from '../../../state'; +import { getHostIsolationExceptionItems, deleteHostIsolationExceptionItems } from '../service'; import { HostIsolationExceptionsPageState } from '../types'; import { initialHostIsolationExceptionsPageState } from './builders'; import { createHostIsolationExceptionsPageMiddleware } from './middleware'; @@ -24,6 +28,7 @@ import { getListFetchError } from './selector'; jest.mock('../service'); const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; +const deleteHostIsolationExceptionItemsMock = deleteHostIsolationExceptionItems as jest.Mock; const fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); @@ -139,4 +144,69 @@ describe('Host isolation exceptions middleware', () => { }); }); }); + + describe('When deleting an item from host isolation exceptions', () => { + beforeEach(() => { + deleteHostIsolationExceptionItemsMock.mockClear(); + deleteHostIsolationExceptionItemsMock.mockReturnValue(undefined); + getHostIsolationExceptionItemsMock.mockClear(); + getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); + store.dispatch({ + type: 'hostIsolationExceptionsMarkToDelete', + payload: { + id: '1', + }, + }); + }); + + it('should call the delete exception API when a delete is submitted and advertise a loading status', async () => { + const waiter = Promise.all([ + // delete loading action + spyMiddleware.waitForAction('hostIsolationExceptionsDeleteStatusChanged', { + validate({ payload }) { + return isLoadingResourceState(payload); + }, + }), + // delete finished action + spyMiddleware.waitForAction('hostIsolationExceptionsDeleteStatusChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }), + ]); + store.dispatch({ + type: 'hostIsolationExceptionsSubmitDelete', + }); + await waiter; + expect(deleteHostIsolationExceptionItemsMock).toHaveBeenLastCalledWith( + fakeCoreStart.http, + '1' + ); + }); + + it('should dispatch a failure if the API returns an error', async () => { + deleteHostIsolationExceptionItemsMock.mockRejectedValue({ + body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsSubmitDelete', + }); + await spyMiddleware.waitForAction('hostIsolationExceptionsDeleteStatusChanged', { + validate({ payload }) { + return isFailedResourceState(payload); + }, + }); + }); + + it('should reload the host isolation exception lists after delete', async () => { + store.dispatch({ + type: 'hostIsolationExceptionsSubmitDelete', + }); + await spyMiddleware.waitForAction('hostIsolationExceptionsPageDataChanged', { + validate({ payload }) { + return isLoadingResourceState(payload); + }, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts index 1df0ef229d2ef..4946cac488700 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts @@ -5,8 +5,11 @@ * 2.0. */ -import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { CoreStart, HttpStart } from 'kibana/public'; +import { + ExceptionListItemSchema, + FoundExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { CoreStart, HttpSetup, HttpStart } from 'kibana/public'; import { matchPath } from 'react-router-dom'; import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; import { ImmutableMiddleware, ImmutableMiddlewareAPI } from '../../../../common/store'; @@ -17,9 +20,9 @@ import { createFailedResourceState, createLoadedResourceState, } from '../../../state/async_resource_builders'; -import { getHostIsolationExceptionItems } from '../service'; +import { deleteHostIsolationExceptionItems, getHostIsolationExceptionItems } from '../service'; import { HostIsolationExceptionsPageState } from '../types'; -import { getCurrentListPageDataState, getCurrentLocation } from './selector'; +import { getCurrentListPageDataState, getCurrentLocation, getItemToDelete } from './selector'; export const SEARCHABLE_FIELDS: Readonly<string[]> = [`name`, `description`, `entries.value`]; @@ -36,6 +39,9 @@ export const createHostIsolationExceptionsPageMiddleware = ( if (action.type === 'userChangedUrl' && isHostIsolationExceptionsPage(action.payload)) { loadHostIsolationExceptionsList(store, coreStart.http); } + if (action.type === 'hostIsolationExceptionsSubmitDelete') { + deleteHostIsolationExceptionsItem(store, coreStart.http); + } }; }; @@ -88,3 +94,37 @@ function isHostIsolationExceptionsPage(location: Immutable<AppLocation>) { }) !== null ); } + +async function deleteHostIsolationExceptionsItem( + store: ImmutableMiddlewareAPI<HostIsolationExceptionsPageState, AppAction>, + http: HttpSetup +) { + const { dispatch } = store; + const itemToDelete = getItemToDelete(store.getState()); + if (itemToDelete === undefined) { + return; + } + try { + dispatch({ + type: 'hostIsolationExceptionsDeleteStatusChanged', + payload: { + type: 'LoadingResourceState', + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) + previousState: store.getState().deletion.status, + }, + }); + + await deleteHostIsolationExceptionItems(http, itemToDelete.id); + + dispatch({ + type: 'hostIsolationExceptionsDeleteStatusChanged', + payload: createLoadedResourceState(itemToDelete), + }); + loadHostIsolationExceptionsList(store, http); + } catch (error) { + dispatch({ + type: 'hostIsolationExceptionsDeleteStatusChanged', + payload: createFailedResourceState<ExceptionListItemSchema>(error.body ?? error), + }); + } +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts index 1bce76c1bfd06..09182661a80b3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts @@ -16,6 +16,7 @@ import { HostIsolationExceptionsPageState } from '../types'; import { initialHostIsolationExceptionsPageState } from './builders'; import { MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../common/constants'; import { UserChangedUrl } from '../../../../common/store/routing/action'; +import { createUninitialisedResourceState } from '../../../state'; type StateReducer = ImmutableReducer<HostIsolationExceptionsPageState, AppAction>; type CaseReducer<T extends AppAction> = ( @@ -45,6 +46,23 @@ export const hostIsolationExceptionsPageReducer: StateReducer = ( } case 'userChangedUrl': return userChangedUrl(state, action); + case 'hostIsolationExceptionsMarkToDelete': { + return { + ...state, + deletion: { + item: action.payload, + status: createUninitialisedResourceState(), + }, + }; + } + case 'hostIsolationExceptionsDeleteStatusChanged': + return { + ...state, + deletion: { + ...state.deletion, + status: action.payload, + }, + }; } return state; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/selector.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/selector.ts index 0ddfc0953263c..4462864e90702 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/selector.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/selector.ts @@ -20,6 +20,7 @@ import { import { getLastLoadedResourceState, isFailedResourceState, + isLoadedResourceState, isLoadingResourceState, } from '../../../state/async_resource_state'; import { HostIsolationExceptionsPageState } from '../types'; @@ -73,3 +74,37 @@ export const getListFetchError: HostIsolationExceptionsSelector< export const getCurrentLocation: HostIsolationExceptionsSelector<StoreState['location']> = ( state ) => state.location; + +export const getDeletionState: HostIsolationExceptionsSelector<StoreState['deletion']> = + createSelector(getCurrentListPageState, (listState) => listState.deletion); + +export const showDeleteModal: HostIsolationExceptionsSelector<boolean> = createSelector( + getDeletionState, + ({ item }) => { + return Boolean(item); + } +); + +export const getItemToDelete: HostIsolationExceptionsSelector<StoreState['deletion']['item']> = + createSelector(getDeletionState, ({ item }) => item); + +export const isDeletionInProgress: HostIsolationExceptionsSelector<boolean> = createSelector( + getDeletionState, + ({ status }) => { + return isLoadingResourceState(status); + } +); + +export const wasDeletionSuccessful: HostIsolationExceptionsSelector<boolean> = createSelector( + getDeletionState, + ({ status }) => { + return isLoadedResourceState(status); + } +); + +export const getDeleteError: HostIsolationExceptionsSelector<ServerApiError | undefined> = + createSelector(getDeletionState, ({ status }) => { + if (isFailedResourceState(status)) { + return status.error; + } + }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts index 44f3d2a9df764..443a86fefab83 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts @@ -5,7 +5,10 @@ * 2.0. */ -import type { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { + ExceptionListItemSchema, + FoundExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { AsyncResourceState } from '../../state/async_resource_state'; export interface HostIsolationExceptionsPageLocation { @@ -20,4 +23,8 @@ export interface HostIsolationExceptionsPageLocation { export interface HostIsolationExceptionsPageState { entries: AsyncResourceState<FoundExceptionListItemSchema>; location: HostIsolationExceptionsPageLocation; + deletion: { + item?: ExceptionListItemSchema; + status: AsyncResourceState<ExceptionListItemSchema>; + }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.test.tsx new file mode 100644 index 0000000000000..0b09b4bfa14c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.test.tsx @@ -0,0 +1,135 @@ +/* + * 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 { act } from '@testing-library/react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import { HostIsolationExceptionDeleteModal } from './delete_modal'; +import { isFailedResourceState, isLoadedResourceState } from '../../../../state'; +import { getHostIsolationExceptionItems, deleteHostIsolationExceptionItems } from '../../service'; +import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { fireEvent } from '@testing-library/dom'; + +jest.mock('../../service'); +const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; +const deleteHostIsolationExceptionItemsMock = deleteHostIsolationExceptionItems as jest.Mock; + +describe('When on the host isolation exceptions delete modal', () => { + let render: () => ReturnType<AppContextTestRender['render']>; + let renderResult: ReturnType<typeof render>; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + let coreStart: AppContextTestRender['coreStart']; + + beforeEach(() => { + const itemToDelete = getExceptionListItemSchemaMock(); + getHostIsolationExceptionItemsMock.mockReset(); + deleteHostIsolationExceptionItemsMock.mockReset(); + const mockedContext = createAppRootMockRenderer(); + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsMarkToDelete', + payload: itemToDelete, + }); + render = () => (renderResult = mockedContext.render(<HostIsolationExceptionDeleteModal />)); + waitForAction = mockedContext.middlewareSpy.waitForAction; + ({ coreStart } = mockedContext); + }); + + it('should render the delete modal with the cancel and submit buttons', () => { + render(); + expect(renderResult.getByTestId('hostIsolationExceptionsDeleteModalCancelButton')).toBeTruthy(); + expect( + renderResult.getByTestId('hostIsolationExceptionsDeleteModalConfirmButton') + ).toBeTruthy(); + }); + + it('should disable the buttons when confirm is pressed and show loading', async () => { + render(); + + const submitButton = renderResult.baseElement.querySelector( + '[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]' + )! as HTMLButtonElement; + + const cancelButton = renderResult.baseElement.querySelector( + '[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]' + )! as HTMLButtonElement; + + act(() => { + fireEvent.click(submitButton); + }); + + expect(submitButton.disabled).toBe(true); + expect(cancelButton.disabled).toBe(true); + expect(submitButton.querySelector('.euiLoadingSpinner')).not.toBeNull(); + }); + + it('should clear the item marked to delete when cancel is pressed', async () => { + render(); + const cancelButton = renderResult.baseElement.querySelector( + '[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]' + )! as HTMLButtonElement; + + const waiter = waitForAction('hostIsolationExceptionsMarkToDelete', { + validate: ({ payload }) => { + return payload === undefined; + }, + }); + + act(() => { + fireEvent.click(cancelButton); + }); + await waiter; + }); + + it('should show success toast after the delete is completed', async () => { + render(); + const updateCompleted = waitForAction('hostIsolationExceptionsDeleteStatusChanged', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + const submitButton = renderResult.baseElement.querySelector( + '[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]' + )! as HTMLButtonElement; + + await act(async () => { + fireEvent.click(submitButton); + await updateCompleted; + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + '"some name" has been removed from the Host Isolation Exceptions list.' + ); + }); + + it('should show error toast if error is encountered', async () => { + deleteHostIsolationExceptionItemsMock.mockRejectedValue( + new Error("That's not true. That's impossible") + ); + render(); + const updateFailure = waitForAction('hostIsolationExceptionsDeleteStatusChanged', { + validate(action) { + return isFailedResourceState(action.payload); + }, + }); + + const submitButton = renderResult.baseElement.querySelector( + '[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]' + )! as HTMLButtonElement; + + await act(async () => { + fireEvent.click(submitButton); + await updateFailure; + }); + + expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( + 'Unable to remove "some name" from the Host Isolation Exceptions list. Reason: That\'s not true. That\'s impossible' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.tsx new file mode 100644 index 0000000000000..61b0bb7f930c3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useEffect } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import { i18n } from '@kbn/i18n'; +import { useToasts } from '../../../../../common/lib/kibana'; +import { useHostIsolationExceptionsSelector } from '../hooks'; +import { + getDeleteError, + getItemToDelete, + isDeletionInProgress, + wasDeletionSuccessful, +} from '../../store/selector'; +import { HostIsolationExceptionsPageAction } from '../../store/action'; + +export const HostIsolationExceptionDeleteModal = memo<{}>(() => { + const dispatch = useDispatch<Dispatch<HostIsolationExceptionsPageAction>>(); + const toasts = useToasts(); + + const isDeleting = useHostIsolationExceptionsSelector(isDeletionInProgress); + const exception = useHostIsolationExceptionsSelector(getItemToDelete); + const wasDeleted = useHostIsolationExceptionsSelector(wasDeletionSuccessful); + const deleteError = useHostIsolationExceptionsSelector(getDeleteError); + + const onCancel = useCallback(() => { + dispatch({ type: 'hostIsolationExceptionsMarkToDelete', payload: undefined }); + }, [dispatch]); + + const onConfirm = useCallback(() => { + dispatch({ type: 'hostIsolationExceptionsSubmitDelete' }); + }, [dispatch]); + + // Show toast for success + useEffect(() => { + if (wasDeleted) { + toasts.addSuccess( + i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.deletionDialog.deleteSuccess', + { + defaultMessage: '"{name}" has been removed from the Host Isolation Exceptions list.', + values: { name: exception?.name }, + } + ) + ); + + dispatch({ type: 'hostIsolationExceptionsMarkToDelete', payload: undefined }); + } + }, [dispatch, exception?.name, toasts, wasDeleted]); + + // show toast for failures + useEffect(() => { + if (deleteError) { + toasts.addDanger( + i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.deletionDialog.deleteFailure', + { + defaultMessage: + 'Unable to remove "{name}" from the Host Isolation Exceptions list. Reason: {message}', + values: { name: exception?.name, message: deleteError.message }, + } + ) + ); + } + }, [deleteError, exception?.name, toasts]); + + return ( + <EuiModal onClose={onCancel}> + <EuiModalHeader data-test-subj="hostIsolationExceptionsDeleteModalHeader"> + <EuiModalHeaderTitle> + <FormattedMessage + id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.title" + defaultMessage="Delete Host Isolation Exception" + /> + </EuiModalHeaderTitle> + </EuiModalHeader> + + <EuiModalBody data-test-subj="hostIsolationExceptionsFilterDeleteModalBody"> + <EuiText> + <p> + <FormattedMessage + id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.subtitle" + defaultMessage='You are deleting exception "{name}".' + values={{ name: <b className="eui-textBreakWord">{exception?.name}</b> }} + /> + </p> + <p> + <FormattedMessage + id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.confirmation" + defaultMessage="This action cannot be undone. Are you sure you wish to continue?" + /> + </p> + </EuiText> + </EuiModalBody> + + <EuiModalFooter> + <EuiButtonEmpty + onClick={onCancel} + isDisabled={isDeleting} + data-test-subj="hostIsolationExceptionsDeleteModalCancelButton" + > + <FormattedMessage + id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.cancel" + defaultMessage="Cancel" + /> + </EuiButtonEmpty> + + <EuiButton + fill + color="danger" + onClick={onConfirm} + isLoading={isDeleting} + data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton" + > + <FormattedMessage + id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.confirmButton" + defaultMessage="Remove exception" + /> + </EuiButton> + </EuiModalFooter> + </EuiModal> + ); +}); + +HostIsolationExceptionDeleteModal.displayName = 'HostIsolationExceptionDeleteModal'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx index f6198e4e1aa54..53fb74d5bd8f7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -7,12 +7,14 @@ import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { i18n } from '@kbn/i18n'; -import React, { useCallback } from 'react'; +import React, { Dispatch, useCallback } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { useDispatch } from 'react-redux'; import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; import { getCurrentLocation, + getItemToDelete, getListFetchError, getListIsLoading, getListItems, @@ -28,18 +30,29 @@ import { AdministrationListPage } from '../../../components/administration_list_ import { SearchExceptions } from '../../../components/search_exceptions'; import { ArtifactEntryCard, ArtifactEntryCardProps } from '../../../components/artifact_entry_card'; import { HostIsolationExceptionsEmptyState } from './components/empty'; +import { HostIsolationExceptionsPageAction } from '../store/action'; +import { HostIsolationExceptionDeleteModal } from './components/delete_modal'; type HostIsolationExceptionPaginatedContent = PaginatedContentProps< Immutable<ExceptionListItemSchema>, typeof ExceptionItem >; +const DELETE_HOST_ISOLATION_EXCEPTION_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.list.actions.delete', + { + defaultMessage: 'Delete Exception', + } +); + export const HostIsolationExceptionsList = () => { const listItems = useHostIsolationExceptionsSelector(getListItems); const pagination = useHostIsolationExceptionsSelector(getListPagination); const isLoading = useHostIsolationExceptionsSelector(getListIsLoading); const fetchError = useHostIsolationExceptionsSelector(getListFetchError); const location = useHostIsolationExceptionsSelector(getCurrentLocation); + const dispatch = useDispatch<Dispatch<HostIsolationExceptionsPageAction>>(); + const itemToDelete = useHostIsolationExceptionsSelector(getItemToDelete); const navigateCallback = useHostIsolationExceptionsNavigateCallback(); @@ -53,6 +66,19 @@ export const HostIsolationExceptionsList = () => { const handleItemComponentProps = (element: ExceptionListItemSchema): ArtifactEntryCardProps => ({ item: element, 'data-test-subj': `hostIsolationExceptionsCard`, + actions: [ + { + icon: 'trash', + onClick: () => { + dispatch({ + type: 'hostIsolationExceptionsMarkToDelete', + payload: element, + }); + }, + 'data-test-subj': 'deleteHostIsolationException', + children: DELETE_HOST_ISOLATION_EXCEPTION_LABEL, + }, + ], }); const handlePaginatedContentChange: HostIsolationExceptionPaginatedContent['onChange'] = @@ -87,6 +113,7 @@ export const HostIsolationExceptionsList = () => { )} /> <EuiSpacer size="l" /> + {itemToDelete ? <HostIsolationExceptionDeleteModal /> : null} <PaginatedContent<ExceptionListItemSchema, typeof ArtifactEntryCard> items={listItems} ItemComponent={ArtifactEntryCard} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index 2d21ec9565476..5fa2725f9ee6f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -41,30 +41,17 @@ describe('OverviewEmpty', () => { (useUserPrivileges as jest.Mock).mockReset(); }); - test('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ - beats: { - description: - 'Lightweight Beats can send data from hundreds or thousands of machines and systems', - fill: false, - label: 'Add data with Beats', - url: '/app/home#/tutorial_directory/security', - }, - elasticAgent: { - description: - 'The Elastic Agent provides a simple, unified way to add monitoring to your hosts.', - fill: false, - label: 'Add data with Elastic Agent', - url: 'ingestUrl', - }, - endpoint: { - description: - 'Protect your hosts with threat prevention, detection, and deep security data visibility.', - fill: false, - label: 'Add Endpoint Security', - onClick: undefined, - url: `/integrations/endpoint-${endpointPackageVersion}/add-integration`, + it('render with correct actions ', () => { + expect(wrapper.find('[data-test-subj="empty-page"]').prop('noDataConfig')).toEqual({ + actions: { + elasticAgent: { + description: + 'Use Elastic Agent to collect security events and protect your endpoints from threats. Manage your agents in Fleet and add integrations with a single click.', + href: '/app/integrations/browse/security', + }, }, + docsLink: 'https://www.elastic.co/guide/en/security/mocked-test-branch/index.html', + solution: 'Security', }); }); }); @@ -78,15 +65,15 @@ describe('OverviewEmpty', () => { wrapper = shallow(<OverviewEmpty />); }); - test('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ - beats: { - description: - 'Lightweight Beats can send data from hundreds or thousands of machines and systems', - fill: false, - label: 'Add data with Beats', - url: '/app/home#/tutorial_directory/security', + it('render with correct actions ', () => { + expect(wrapper.find('[data-test-subj="empty-page"]').prop('noDataConfig')).toEqual({ + actions: { + beats: { + href: '/app/home#/tutorial_directory/security', + }, }, + docsLink: 'https://www.elastic.co/guide/en/security/mocked-test-branch/index.html', + solution: 'Security', }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 6f885b348cdeb..bc76333943191 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -6,105 +6,57 @@ */ import React, { useMemo } from 'react'; -import { omit } from 'lodash/fp'; -import { createStructuredSelector } from 'reselect'; - -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiLink } from '@elastic/eui'; -import * as i18nCommon from '../../../common/translations'; -import { EmptyPage, EmptyPageActionsProps } from '../../../common/components/empty_page'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../common/lib/kibana'; import { ADD_DATA_PATH } from '../../../../common/constants'; -import { - useEndpointSelector, - useIngestUrl, -} from '../../../management/pages/endpoint_hosts/view/hooks'; -import { useNavigateToAppEventHandler } from '../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; -import { CreateStructuredSelector } from '../../../common/store'; -import { endpointPackageVersion as useEndpointPackageVersion } from '../../../management/pages/endpoint_hosts/store/selectors'; +import { pagePathGetters } from '../../../../../fleet/public'; +import { SOLUTION_NAME } from '../../../../public/common/translations'; import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { + KibanaPageTemplate, + NoDataPageActionsProps, +} from '../../../../../../../src/plugins/kibana_react/public'; + const OverviewEmptyComponent: React.FC = () => { const { http, docLinks } = useKibana().services; const basePath = http.basePath.get(); - const selector = (createStructuredSelector as CreateStructuredSelector)({ - endpointPackageVersion: useEndpointPackageVersion, - }); - const { endpointPackageVersion } = useEndpointSelector(selector); - const { url: ingestUrl } = useIngestUrl(''); - - const endpointIntegrationUrlPath = endpointPackageVersion - ? `/endpoint-${endpointPackageVersion}/add-integration` - : ''; - const endpointIntegrationUrl = `/integrations${endpointIntegrationUrlPath}`; - const handleEndpointClick = useNavigateToAppEventHandler('fleet', { - path: endpointIntegrationUrl, - }); const canAccessFleet = useUserPrivileges().endpointPrivileges.canAccessFleet; + const integrationsPathComponents = pagePathGetters.integrations_all({ category: 'security' }); - const emptyPageActions: EmptyPageActionsProps = useMemo( + const agentAction: NoDataPageActionsProps = useMemo( () => ({ elasticAgent: { - label: i18nCommon.EMPTY_ACTION_ELASTIC_AGENT, - url: ingestUrl, - description: i18nCommon.EMPTY_ACTION_ELASTIC_AGENT_DESCRIPTION, - fill: false, - }, - beats: { - label: i18nCommon.EMPTY_ACTION_BEATS, - url: `${basePath}${ADD_DATA_PATH}`, - description: i18nCommon.EMPTY_ACTION_BEATS_DESCRIPTION, - fill: false, - }, - endpoint: { - label: i18nCommon.EMPTY_ACTION_ENDPOINT, - url: endpointIntegrationUrl, - description: i18nCommon.EMPTY_ACTION_ENDPOINT_DESCRIPTION, - onClick: handleEndpointClick, - fill: false, + href: `${basePath}${integrationsPathComponents[0]}${integrationsPathComponents[1]}`, + description: i18n.translate( + 'xpack.securitySolution.pages.emptyPage.beatsCard.description', + { + defaultMessage: + 'Use Elastic Agent to collect security events and protect your endpoints from threats. Manage your agents in Fleet and add integrations with a single click.', + } + ), }, }), - [basePath, ingestUrl, endpointIntegrationUrl, handleEndpointClick] + [basePath, integrationsPathComponents] ); - const emptyPageIngestDisabledActions = useMemo( - () => omit(['elasticAgent', 'endpoint'], emptyPageActions), - [emptyPageActions] + const beatsAction: NoDataPageActionsProps = useMemo( + () => ({ + beats: { + href: `${basePath}${ADD_DATA_PATH}`, + }, + }), + [basePath] ); - return canAccessFleet === true ? ( - <EmptyPage - actions={emptyPageActions} - data-test-subj="empty-page" - message={ - <> - <FormattedMessage - id="xpack.securitySolution.emptyMessage" - defaultMessage="Elastic Security integrates the free and open Elastic SIEM with Endpoint Security to prevent, detect, and respond to threats. To begin, you’ll need to add security solution related data to the Elastic Stack. For additional information, you can view our " - /> - <EuiLink href={docLinks.links.siem.gettingStarted} target="_blank"> - {i18nCommon.EMPTY_ACTION_SECONDARY} - </EuiLink> - </> - } - title={i18nCommon.EMPTY_TITLE} - /> - ) : ( - <EmptyPage - actions={emptyPageIngestDisabledActions} + return ( + <KibanaPageTemplate data-test-subj="empty-page" - message={ - <> - <FormattedMessage - id="xpack.securitySolution.emptyMessage" - defaultMessage="Elastic Security integrates the free and open Elastic SIEM with Endpoint Security to prevent, detect, and respond to threats. To begin, you’ll need to add security solution related data to the Elastic Stack. For additional information, you can view our " - /> - <EuiLink href={docLinks.links.siem.gettingStarted} target="_blank"> - {i18nCommon.EMPTY_ACTION_SECONDARY} - </EuiLink> - </> - } - title={i18nCommon.EMPTY_TITLE} + noDataConfig={{ + solution: SOLUTION_NAME, + actions: canAccessFleet ? agentAction : beatsAction, + docsLink: docLinks.links.siem.gettingStarted, + }} /> ); }; diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index aadedda5d6233..66f431522176e 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -299,29 +299,6 @@ describe('Overview', () => { ); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); }); - - it('does not show Endpoint get ready button when ingest is not enabled', () => { - const wrapper = mount( - <TestProviders> - <MemoryRouter> - <Overview /> - </MemoryRouter> - </TestProviders> - ); - expect(wrapper.find('[data-test-subj="empty-page-endpoint-action"]').exists()).toBe(false); - }); - - it('shows Endpoint get ready button when ingest is enabled', () => { - mockUseUserPrivileges.mockReturnValue(loadedUserPrivilegesState({ canAccessFleet: true })); - const wrapper = mount( - <TestProviders> - <MemoryRouter> - <Overview /> - </MemoryRouter> - </TestProviders> - ); - expect(wrapper.find('[data-test-subj="empty-page-endpoint-action"]').exists()).toBe(true); - }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/pages/translations.ts b/x-pack/plugins/security_solution/public/overview/pages/translations.ts index f2d90bf8bf370..cf5254a7e3137 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/pages/translations.ts @@ -18,10 +18,6 @@ export const NEWS_FEED_TITLE = i18n.translate( } ); -export const PAGE_TITLE = i18n.translate('xpack.securitySolution.overview.pageTitle', { - defaultMessage: 'Security', -}); - export const PAGE_SUBTITLE = i18n.translate('xpack.securitySolution.overview.pageSubtitle', { defaultMessage: 'Security Information & Event Management with the Elastic Stack', }); diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index cd65808f28bce..fe746d2e8e8d8 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -33,6 +33,7 @@ import { import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; +import { SOLUTION_NAME } from './common/translations'; import { APP_ID, @@ -104,7 +105,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S if (plugins.home) { plugins.home.featureCatalogue.registerSolution({ id: APP_ID, - title: APP_NAME, + title: SOLUTION_NAME, description: i18n.translate('xpack.securitySolution.featureCatalogueDescription', { defaultMessage: 'Prevent, collect, detect, and respond to threats for unified protection across your infrastructure.', @@ -134,7 +135,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S core.application.register({ id: APP_ID, - title: APP_NAME, + title: SOLUTION_NAME, appRoute: APP_PATH, category: DEFAULT_APP_CATEGORIES.security, navLinkStatus: AppNavLinkStatus.hidden, @@ -433,7 +434,3 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S return this._store; } } - -const APP_NAME = i18n.translate('xpack.securitySolution.security.title', { - defaultMessage: 'Security', -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 69b63e83186e3..5d52d2c8a4d48 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -939,17 +939,46 @@ describe('helpers', () => { }); describe('queryTimelineById', () => { + describe('encounters failure when retrieving a timeline', () => { + const onError = jest.fn(); + const mockError = new Error('failed'); + + const args = { + timelineId: '123', + onError, + updateIsLoading: jest.fn(), + updateTimeline: jest.fn(), + }; + + beforeAll(async () => { + (getTimeline as jest.Mock).mockRejectedValue(mockError); + queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('calls onError with the error', () => { + expect(onError).toHaveBeenCalledWith(mockError, '123'); + }); + }); + describe('open a timeline', () => { - const updateIsLoading = jest.fn(); const selectedTimeline = { ...mockSelectedTimeline, }; + + const updateIsLoading = jest.fn(); const onOpenTimeline = jest.fn(); + const onError = jest.fn(); + const args = { duplicate: false, graphEventId: '', timelineId: '', timelineType: TimelineType.default, + onError, onOpenTimeline, openTimeline: true, updateIsLoading, @@ -976,6 +1005,10 @@ describe('helpers', () => { expect(getTimeline).toHaveBeenCalled(); }); + test('it does not call onError when an error does not occur', () => { + expect(onError).not.toHaveBeenCalled(); + }); + test('Do not override daterange if TimelineStatus is active', () => { const { timeline } = formatTimelineResultToModel( omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)), diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index c72aa5878478d..2a3b49517b456 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -50,7 +50,12 @@ import { DEFAULT_COLUMN_MIN_WIDTH, } from '../timeline/body/constants'; -import { OpenTimelineResult, UpdateTimeline, DispatchUpdateTimeline } from './types'; +import { + OpenTimelineResult, + UpdateTimeline, + DispatchUpdateTimeline, + TimelineErrorCallback, +} from './types'; import { createNote } from '../notes/helpers'; import { IS_OPERATOR } from '../timeline/data_providers/data_provider'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; @@ -313,6 +318,7 @@ export interface QueryTimelineById<TCache> { graphEventId?: string; timelineId: string; timelineType?: TimelineType; + onError?: TimelineErrorCallback; onOpenTimeline?: (timeline: TimelineModel) => void; openTimeline?: boolean; updateIsLoading: ({ @@ -331,6 +337,7 @@ export const queryTimelineById = <TCache>({ graphEventId = '', timelineId, timelineType, + onError, onOpenTimeline, openTimeline = true, updateIsLoading, @@ -372,6 +379,11 @@ export const queryTimelineById = <TCache>({ })(); } }) + .catch((error) => { + if (onError != null) { + onError(error, timelineId); + } + }) .finally(() => { updateIsLoading({ id: TimelineId.active, isLoading: false }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index cddf4e8d71d60..79a700856c00f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -235,3 +235,5 @@ export interface TemplateTimelineFilter { withNext: boolean; count: number | undefined; } + +export type TimelineErrorCallback = (error: Error, timelineId: string) => void; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts new file mode 100644 index 0000000000000..b9b70c4c1da19 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts @@ -0,0 +1,111 @@ +/* + * 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 { run, RunFn, createFailError } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; +import { AxiosError } from 'axios'; +import bluebird from 'bluebird'; +import type { CreateExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME, + EXCEPTION_LIST_ITEM_URL, + EXCEPTION_LIST_URL, +} from '@kbn/securitysolution-list-constants'; +import { HostIsolationExceptionGenerator } from '../../../common/endpoint/data_generators/host_isolation_exception_generator'; + +export const cli = () => { + run( + async (options) => { + try { + await createHostIsolationException(options); + options.log.success(`${options.flags.count} endpoint host isolation exceptions`); + } catch (e) { + options.log.error(e); + throw createFailError(e.message); + } + }, + { + description: 'Load Host Isolation Exceptions', + flags: { + string: ['kibana'], + default: { + count: 10, + kibana: 'http://elastic:changeme@localhost:5601', + }, + help: ` + --count Number of host isolation exceptions to create. Default: 10 + --kibana The URL to kibana including credentials. Default: http://elastic:changeme@localhost:5601 + `, + }, + } + ); +}; + +class EventFilterDataLoaderError extends Error { + constructor(message: string, public readonly meta: unknown) { + super(message); + } +} + +const handleThrowAxiosHttpError = (err: AxiosError): never => { + let message = err.message; + + if (err.response) { + message = `[${err.response.status}] ${err.response.data.message ?? err.message} [ ${String( + err.response.config.method + ).toUpperCase()} ${err.response.config.url} ]`; + } + throw new EventFilterDataLoaderError(message, err.toJSON()); +}; + +const createHostIsolationException: RunFn = async ({ flags, log }) => { + const eventGenerator = new HostIsolationExceptionGenerator(); + const kbn = new KbnClient({ log, url: flags.kibana as string }); + + await ensureCreateEndpointHostIsolationExceptionList(kbn); + + await bluebird.map( + Array.from({ length: flags.count as unknown as number }), + () => + kbn + .request({ + method: 'POST', + path: EXCEPTION_LIST_ITEM_URL, + body: eventGenerator.generate(), + }) + .catch((e) => handleThrowAxiosHttpError(e)), + { concurrency: 10 } + ); +}; + +const ensureCreateEndpointHostIsolationExceptionList = async (kbn: KbnClient) => { + const newListDefinition: CreateExceptionListSchema = { + description: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION, + list_id: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + meta: undefined, + name: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME, + os_types: [], + tags: [], + type: 'endpoint', + namespace_type: 'agnostic', + }; + + await kbn + .request({ + method: 'POST', + path: EXCEPTION_LIST_URL, + body: newListDefinition, + }) + .catch((e) => { + // Ignore if list was already created + if (e.response.status !== 409) { + handleThrowAxiosHttpError(e); + } + }); +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/load_host_isolation_exceptions.js b/x-pack/plugins/security_solution/scripts/endpoint/load_host_isolation_exceptions.js new file mode 100644 index 0000000000000..13fedecb690ca --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/load_host_isolation_exceptions.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +/* + * 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. + */ + +require('../../../../../src/setup_node_env'); +require('./host_isolation_exceptions').cli(); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index ed5dbbd09d79a..71df9902223da 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -151,6 +151,7 @@ describe('Host Isolation', () => { type: ElasticsearchAssetType.transform, }, ], + keep_policies_up_to_date: false, }) ); licenseEmitter = new Subject(); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 3fa90ad6d27a5..d9016e7a9c7cb 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -131,6 +131,7 @@ describe('test endpoint route', () => { type: ElasticsearchAssetType.transform, }, ], + keep_policies_up_to_date: false, }) ); endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); @@ -390,6 +391,7 @@ describe('test endpoint route', () => { type: ElasticsearchAssetType.transform, }, ], + keep_policies_up_to_date: false, }) ); endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); diff --git a/x-pack/plugins/spaces/public/mocks.ts b/x-pack/plugins/spaces/public/mocks.ts index 897f58e1d649c..76cafd4c7f5ae 100644 --- a/x-pack/plugins/spaces/public/mocks.ts +++ b/x-pack/plugins/spaces/public/mocks.ts @@ -41,6 +41,7 @@ const createApiUiComponentsMock = () => { getSpaceList: jest.fn(), getLegacyUrlConflict: jest.fn(), getSpaceAvatar: jest.fn(), + getSavedObjectConflictMessage: jest.fn(), }; return mock; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/get_saved_object_conflict_message.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/get_saved_object_conflict_message.tsx new file mode 100644 index 0000000000000..66b2a5652057a --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/get_saved_object_conflict_message.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 React from 'react'; + +import type { SavedObjectConflictMessageProps } from '../types'; + +export const getSavedObjectConflictMessage = async (): Promise< + React.FC<SavedObjectConflictMessageProps> +> => { + const { SavedObjectConflictMessage } = await import('./saved_object_conflict_message'); + return (props: SavedObjectConflictMessageProps) => { + return <SavedObjectConflictMessage {...props} />; + }; +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts index c0828e3b5331d..fa641d03fd715 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts @@ -6,4 +6,5 @@ */ export { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; +export { getSavedObjectConflictMessage } from './get_saved_object_conflict_message'; export { getLegacyUrlConflict } from './legacy_url_conflict'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/saved_object_conflict_message.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/saved_object_conflict_message.tsx new file mode 100644 index 0000000000000..22a1ad7cd20aa --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/saved_object_conflict_message.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty, EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; +import React, { useState } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import type { SavedObjectConflictMessageProps } from '../types'; + +export const SavedObjectConflictMessage = ({ json }: SavedObjectConflictMessageProps) => { + const [expandError, setExpandError] = useState(false); + return ( + <> + <FormattedMessage + id="xpack.spaces.legacyURLConflict.longMessage" + defaultMessage="Disable the {documentationLink} associated with this object." + values={{ + documentationLink: ( + <EuiLink + external + href="https://www.elastic.co/guide/en/kibana/master/legacy-url-aliases.html" + target="_blank" + > + {i18n.translate('xpack.spaces.legacyURLConflict.documentationLinkText', { + defaultMessage: 'legacy URL alias', + })} + </EuiLink> + ), + }} + /> + <EuiSpacer /> + {expandError ? ( + <EuiCallOut + title={i18n.translate('xpack.spaces.legacyURLConflict.expandErrorText', { + defaultMessage: `This object has the same URL as a legacy alias. Disable the alias to resolve this error : {json}`, + values: { json }, + })} + color="danger" + iconType="alert" + /> + ) : ( + <EuiButtonEmpty onClick={() => setExpandError(true)}> + {i18n.translate('xpack.spaces.legacyURLConflict.expandError', { + defaultMessage: `Show more`, + })} + </EuiButtonEmpty> + )} + </> + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts index fe90ee8d6a8a9..465fd179c8441 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts @@ -5,10 +5,15 @@ * 2.0. */ -export { getShareToSpaceFlyoutComponent, getLegacyUrlConflict } from './components'; +export { + getShareToSpaceFlyoutComponent, + getLegacyUrlConflict, + getSavedObjectConflictMessage, +} from './components'; export { createRedirectLegacyUrl } from './utils'; export type { LegacyUrlConflictProps, ShareToSpaceFlyoutProps, ShareToSpaceSavedObjectTarget, + SavedObjectConflictMessageProps, } from './types'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts index 1beccaa546282..21290f2b90de5 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts @@ -140,3 +140,10 @@ export interface ShareToSpaceSavedObjectTarget { */ noun?: string; } + +/** + * Properties for the SavedObjectConflictMessage component. + */ +export interface SavedObjectConflictMessageProps { + json: string; +} diff --git a/x-pack/plugins/spaces/public/ui_api/components.tsx b/x-pack/plugins/spaces/public/ui_api/components.tsx index a33480712ffae..71dc2e34a0d70 100644 --- a/x-pack/plugins/spaces/public/ui_api/components.tsx +++ b/x-pack/plugins/spaces/public/ui_api/components.tsx @@ -14,6 +14,7 @@ import { getCopyToSpaceFlyoutComponent } from '../copy_saved_objects_to_space'; import type { PluginsStart } from '../plugin'; import { getLegacyUrlConflict, + getSavedObjectConflictMessage, getShareToSpaceFlyoutComponent, } from '../share_saved_objects_to_space'; import { getSpaceAvatarComponent } from '../space_avatar'; @@ -56,5 +57,6 @@ export const getComponents = ({ getSpaceList: wrapLazy(getSpaceListComponent), getLegacyUrlConflict: wrapLazy(() => getLegacyUrlConflict({ getStartServices })), getSpaceAvatar: wrapLazy(getSpaceAvatarComponent), + getSavedObjectConflictMessage: wrapLazy(() => getSavedObjectConflictMessage()), }; }; diff --git a/x-pack/plugins/spaces/public/ui_api/types.ts b/x-pack/plugins/spaces/public/ui_api/types.ts index 5048e5a9b9652..67e43f0cd31a6 100644 --- a/x-pack/plugins/spaces/public/ui_api/types.ts +++ b/x-pack/plugins/spaces/public/ui_api/types.ts @@ -12,6 +12,7 @@ import type { CoreStart } from 'src/core/public'; import type { CopyToSpaceFlyoutProps } from '../copy_saved_objects_to_space'; import type { LegacyUrlConflictProps, + SavedObjectConflictMessageProps, ShareToSpaceFlyoutProps, } from '../share_saved_objects_to_space'; import type { SpaceAvatarProps } from '../space_avatar'; @@ -109,4 +110,8 @@ export interface SpacesApiUiComponent { * Displays an avatar for the given space. */ getSpaceAvatar: LazyComponentFn<SpaceAvatarProps>; + /** + * Displays a saved object conflict message that directs user to disable legacy URL alias + */ + getSavedObjectConflictMessage: LazyComponentFn<SavedObjectConflictMessageProps>; } diff --git a/x-pack/plugins/spaces/server/config.test.ts b/x-pack/plugins/spaces/server/config.test.ts index dfaaff832696e..e8c8b02ef93c2 100644 --- a/x-pack/plugins/spaces/server/config.test.ts +++ b/x-pack/plugins/spaces/server/config.test.ts @@ -8,8 +8,11 @@ import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; import { deepFreeze } from '@kbn/std'; +import { configDeprecationsMock } from '../../../../src/core/server/mocks'; import { spacesConfigDeprecationProvider } from './config'; +const deprecationContext = configDeprecationsMock.createContext(); + const applyConfigDeprecations = (settings: Record<string, any> = {}) => { const deprecations = spacesConfigDeprecationProvider(configDeprecationFactory); const deprecationMessages: string[] = []; @@ -18,6 +21,7 @@ const applyConfigDeprecations = (settings: Record<string, any> = {}) => { deprecations.map((deprecation) => ({ deprecation, path: '', + context: deprecationContext, })), () => ({ message }) => diff --git a/x-pack/plugins/stack_alerts/common/config.ts b/x-pack/plugins/stack_alerts/common/config.ts index ebc12ee563350..e1eb28f092408 100644 --- a/x-pack/plugins/stack_alerts/common/config.ts +++ b/x-pack/plugins/stack_alerts/common/config.ts @@ -7,8 +7,6 @@ import { schema, TypeOf } from '@kbn/config-schema'; -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), -}); +export const configSchema = schema.object({}); export type Config = TypeOf<typeof configSchema>; diff --git a/x-pack/plugins/stack_alerts/server/index.test.ts b/x-pack/plugins/stack_alerts/server/index.test.ts index 9f13996558ae6..7d66367f7d752 100644 --- a/x-pack/plugins/stack_alerts/server/index.test.ts +++ b/x-pack/plugins/stack_alerts/server/index.test.ts @@ -6,8 +6,11 @@ */ import { config } from './index'; import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; +import { configDeprecationsMock } from '../../../../src/core/server/mocks'; const CONFIG_PATH = 'xpack.stack_alerts'; +const deprecationContext = configDeprecationsMock.createContext(); + const applyStackAlertDeprecations = (settings: Record<string, unknown> = {}) => { const deprecations = config.deprecations!(configDeprecationFactory); const deprecationMessages: string[] = []; @@ -19,6 +22,7 @@ const applyStackAlertDeprecations = (settings: Record<string, unknown> = {}) => deprecations.map((deprecation) => ({ deprecation, path: CONFIG_PATH, + context: deprecationContext, })), () => ({ message }) => diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts index e9701fc3e7c05..8d7a6c7872e7e 100644 --- a/x-pack/plugins/task_manager/server/config.test.ts +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -12,7 +12,6 @@ describe('config validation', () => { const config: Record<string, unknown> = {}; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { - "enabled": true, "ephemeral_tasks": Object { "enabled": false, "request_capacity": 10, @@ -71,7 +70,6 @@ describe('config validation', () => { const config: Record<string, unknown> = {}; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { - "enabled": true, "ephemeral_tasks": Object { "enabled": false, "request_capacity": 10, @@ -117,7 +115,6 @@ describe('config validation', () => { }; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { - "enabled": true, "ephemeral_tasks": Object { "enabled": false, "request_capacity": 10, diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts index 286a9feaa1b5e..f2026ecac3adc 100644 --- a/x-pack/plugins/task_manager/server/config.ts +++ b/x-pack/plugins/task_manager/server/config.ts @@ -43,7 +43,6 @@ export const taskExecutionFailureThresholdSchema = schema.object( export const configSchema = schema.object( { - enabled: schema.boolean({ defaultValue: true }), /* The maximum number of times a task will be attempted before being abandoned as failed */ max_attempts: schema.number({ defaultValue: 3, diff --git a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts index 558aa108c2462..ec6f25b7f1b61 100644 --- a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts @@ -42,7 +42,6 @@ describe('EphemeralTaskLifecycle', () => { definitions: new TaskTypeDictionary(taskManagerLogger), executionContext, config: { - enabled: true, max_workers: 10, index: 'foo', max_attempts: 9, diff --git a/x-pack/plugins/task_manager/server/index.test.ts b/x-pack/plugins/task_manager/server/index.test.ts index 8419e826dfd31..ad2d598fe1082 100644 --- a/x-pack/plugins/task_manager/server/index.test.ts +++ b/x-pack/plugins/task_manager/server/index.test.ts @@ -7,9 +7,12 @@ import { config } from './index'; import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; +import { configDeprecationsMock } from '../../../../src/core/server/mocks'; const CONFIG_PATH = 'xpack.task_manager'; +const deprecationContext = configDeprecationsMock.createContext(); + const applyTaskManagerDeprecations = (settings: Record<string, unknown> = {}) => { const deprecations = config.deprecations!(configDeprecationFactory); const deprecationMessages: string[] = []; @@ -21,6 +24,7 @@ const applyTaskManagerDeprecations = (settings: Record<string, unknown> = {}) => deprecations.map((deprecation) => ({ deprecation, path: CONFIG_PATH, + context: deprecationContext, })), () => ({ message }) => @@ -52,13 +56,4 @@ describe('deprecations', () => { ] `); }); - - it('logs a deprecation warning for the enabled config', () => { - const { messages } = applyTaskManagerDeprecations({ enabled: true }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"xpack.task_manager.enabled\\" is deprecated. The ability to disable this plugin will be removed in 8.0.0.", - ] - `); - }); }); diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts index 368b5a3441778..611fa40591c4d 100644 --- a/x-pack/plugins/task_manager/server/index.ts +++ b/x-pack/plugins/task_manager/server/index.ts @@ -68,16 +68,5 @@ export const config: PluginConfigDescriptor<TaskManagerConfig> = { }); } }, - (settings, fromPath, addDeprecation) => { - const taskManager = get(settings, fromPath); - if (taskManager?.enabled === false || taskManager?.enabled === true) { - addDeprecation({ - message: `"xpack.task_manager.enabled" is deprecated. The ability to disable this plugin will be removed in 8.0.0.`, - correctiveActions: { - manualSteps: [`Remove "xpack.task_manager.enabled" from your kibana configs.`], - }, - }); - } - }, ], }; diff --git a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts index f714fd36c2658..c9cc5be2d5cd6 100644 --- a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts +++ b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts @@ -29,7 +29,6 @@ describe('managed configuration', () => { clock = sinon.useFakeTimers(); const context = coreMock.createPluginInitializerContext<TaskManagerConfig>({ - enabled: true, max_workers: 10, index: 'foo', max_attempts: 9, diff --git a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts index 6e88e9803add2..bbd5bc217ae3b 100644 --- a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts @@ -13,7 +13,6 @@ import { TaskManagerConfig } from '../config'; describe('Configuration Statistics Aggregator', () => { test('merges the static config with the merged configs', async () => { const configuration: TaskManagerConfig = { - enabled: true, max_workers: 10, index: 'foo', max_attempts: 9, diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts index ec94d9df1a4dc..e29dbc978c64a 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts @@ -17,7 +17,6 @@ beforeEach(() => { describe('createMonitoringStatsStream', () => { const configuration: TaskManagerConfig = { - enabled: true, max_workers: 10, index: 'foo', max_attempts: 9, diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index c47f006eca415..c2345d7bf8193 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -16,7 +16,6 @@ describe('TaskManagerPlugin', () => { describe('setup', () => { test('throws if no valid UUID is available', async () => { const pluginInitializerContext = coreMock.createPluginInitializerContext<TaskManagerConfig>({ - enabled: true, max_workers: 10, index: 'foo', max_attempts: 9, @@ -59,7 +58,6 @@ describe('TaskManagerPlugin', () => { test('throws if setup methods are called after start', async () => { const pluginInitializerContext = coreMock.createPluginInitializerContext<TaskManagerConfig>({ - enabled: true, max_workers: 10, index: 'foo', max_attempts: 9, @@ -131,7 +129,6 @@ describe('TaskManagerPlugin', () => { test('it logs a warning when the unsafe `exclude_task_types` config is used', async () => { const pluginInitializerContext = coreMock.createPluginInitializerContext<TaskManagerConfig>({ - enabled: true, max_workers: 10, index: 'foo', max_attempts: 9, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index dcbb8ce26ee4a..75d0fac3656a8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1653,12 +1653,6 @@ "data.functions.esaggs.help": "AggConfig 集約を実行します", "data.functions.esaggs.inspector.dataRequest.description": "このリクエストはElasticsearchにクエリし、ビジュアライゼーション用のデータを取得します。", "data.functions.esaggs.inspector.dataRequest.title": "データ", - "dataViews.indexPatternLoad.help": "インデックスパターンを読み込みます", - "dataViews.functions.indexPatternLoad.id.help": "読み込むインデックスパターンID", - "dataViews.ensureDefaultIndexPattern.bannerLabel": "Kibanaでデータの可視化と閲覧を行うには、Elasticsearchからデータを取得するためのインデックスパターンの作成が必要です。", - "dataViews.fetchFieldErrorTitle": "インデックスパターンのフィールド取得中にエラーが発生 {title}(ID:{id})", - "dataViews.indexPatternLoad.error.kibanaRequest": "サーバーでこの検索を実行するには、KibanaRequest が必要です。式実行パラメーターに要求オブジェクトを渡してください。", - "dataViews.unableWriteLabel": "インデックスパターンを書き込めません。このインデックスパターンへの最新の変更を取得するには、ページを更新してください。", "data.inspector.table..dataDescriptionTooltip": "ビジュアライゼーションの元のデータを表示", "data.inspector.table.dataTitle": "データ", "data.inspector.table.downloadCSVToggleButtonLabel": "CSV をダウンロード", @@ -2297,6 +2291,12 @@ "data.searchSessions.sessionService.sessionObjectFetchError": "検索セッション情報を取得できませんでした", "data.triggers.applyFilterDescription": "Kibanaフィルターが適用されるとき。単一の値または範囲フィルターにすることができます。", "data.triggers.applyFilterTitle": "フィルターを適用", + "dataViews.indexPatternLoad.help": "インデックスパターンを読み込みます", + "dataViews.functions.indexPatternLoad.id.help": "読み込むインデックスパターンID", + "dataViews.ensureDefaultIndexPattern.bannerLabel": "Kibanaでデータの可視化と閲覧を行うには、Elasticsearchからデータを取得するためのインデックスパターンの作成が必要です。", + "dataViews.fetchFieldErrorTitle": "インデックスパターンのフィールド取得中にエラーが発生 {title}(ID:{id})", + "dataViews.indexPatternLoad.error.kibanaRequest": "サーバーでこの検索を実行するには、KibanaRequest が必要です。式実行パラメーターに要求オブジェクトを渡してください。", + "dataViews.unableWriteLabel": "インデックスパターンを書き込めません。このインデックスパターンへの最新の変更を取得するには、ページを更新してください。", "devTools.badge.readOnly.text": "読み取り専用", "devTools.badge.readOnly.tooltip": "を保存できませんでした", "devTools.devToolsTitle": "開発ツール", @@ -6310,9 +6310,6 @@ "xpack.apm.exactTransactionRateLabel": "{value} { unit, select, minute {tpm} other {tps} }", "xpack.apm.failedTransactionsCorrelations.licenseCheckText": "失敗したトランザクションの相関関係機能を使用するには、Elastic Platinumライセンスのサブスクリプションが必要です。", "xpack.apm.featureRegistry.apmFeatureName": "APMおよびユーザーエクスペリエンス", - "xpack.apm.featureRegistry.manageAlertsName": "アラート", - "xpack.apm.featureRegistry.subfeature.alertsAllName": "すべて", - "xpack.apm.featureRegistry.subfeature.alertsReadName": "読み取り", "xpack.apm.feedbackMenu.appName": "APM", "xpack.apm.fetcher.error.status": "エラー", "xpack.apm.fetcher.error.title": "リソースの取得中にエラーが発生しました", @@ -6432,24 +6429,6 @@ "xpack.apm.localFilters.titles.serviceName": "サービス名", "xpack.apm.localFilters.titles.transactionUrl": "URL", "xpack.apm.localFiltersTitle": "フィルター", - "xpack.apm.metadataTable.section.agentLabel": "エージェント", - "xpack.apm.metadataTable.section.clientLabel": "クライアント", - "xpack.apm.metadataTable.section.containerLabel": "コンテナー", - "xpack.apm.metadataTable.section.customLabel": "カスタム", - "xpack.apm.metadataTable.section.errorLabel": "エラー", - "xpack.apm.metadataTable.section.hostLabel": "ホスト", - "xpack.apm.metadataTable.section.httpLabel": "HTTP", - "xpack.apm.metadataTable.section.labelsLabel": "ラベル", - "xpack.apm.metadataTable.section.messageLabel": "メッセージ", - "xpack.apm.metadataTable.section.pageLabel": "ページ", - "xpack.apm.metadataTable.section.processLabel": "プロセス", - "xpack.apm.metadataTable.section.serviceLabel": "サービス", - "xpack.apm.metadataTable.section.spanLabel": "スパン", - "xpack.apm.metadataTable.section.traceLabel": "トレース", - "xpack.apm.metadataTable.section.transactionLabel": "トランザクション", - "xpack.apm.metadataTable.section.urlLabel": "URL", - "xpack.apm.metadataTable.section.userAgentLabel": "ユーザーエージェント", - "xpack.apm.metadataTable.section.userLabel": "ユーザー", "xpack.apm.metrics.transactionChart.machineLearningLabel": "機械学習:", "xpack.apm.metrics.transactionChart.machineLearningTooltip": "ストリームには、平均レイテンシの想定境界が表示されます。赤色の垂直の注釈は、異常スコアが75以上の異常値を示します。", "xpack.apm.metrics.transactionChart.machineLearningTooltip.withKuery": "フィルタリングで検索バーを使用しているときには、機械学習結果が表示されません", @@ -9346,11 +9325,9 @@ "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryButtonLabel": "クエリを管理", "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryDescription": "このキュレーションのクエリを編集、追加、削除します。", "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryTitle": "クエリを管理", - "xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.emptyDescription": "表示するオーガニック結果はありません。上記のアクティブなクエリを追加または変更します。", "xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.title": "\"{currentQuery}\"の上位のオーガニックドキュメント", "xpack.enterpriseSearch.appSearch.engine.curations.overview.title": "キュレーションされた結果", "xpack.enterpriseSearch.appSearch.engine.curations.promoteButtonLabel": "この結果を昇格", - "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.description": "昇格された結果はオーガニック結果の前に表示されます。ドキュメントを並べ替えることができます。", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.emptyDescription": "以下のオーガニック結果からドキュメントにスターを付けるか、手動で結果を検索して昇格します。", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel": "すべて降格", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.title": "昇格されたドキュメント", @@ -9974,11 +9951,9 @@ "xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.unsaved.message": "表示設定は保存されていません。終了してよろしいですか?", "xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.visibleFields.title": "表示フィールド", "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementContentExtractionLabel": "すべてのテキストとコンテンツを同期", - "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementDescription": "このソースの特定のコンテンツの抽出を有効および無効にします。", "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementGlobalConfigLabel": "サムネイルを同期 - グローバル構成レベルでは無効", "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementSynchronizeLabel": "このソースを同期", "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementThumbnailsLabel": "サムネイルを同期", - "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementTitle": "同期管理", "xpack.enterpriseSearch.workplaceSearch.copyText": "コピー", "xpack.enterpriseSearch.workplaceSearch.credentials.description": "クライアントで次の資格情報を使用して、認証サーバーからアクセストークンを要求します。", "xpack.enterpriseSearch.workplaceSearch.credentials.title": "資格情報", @@ -12725,9 +12700,7 @@ "xpack.infra.homePage.documentTitle": "メトリック", "xpack.infra.homePage.inventoryTabTitle": "インベントリ", "xpack.infra.homePage.metricsExplorerTabTitle": "メトリックエクスプローラー", - "xpack.infra.homePage.noMetricsIndicesDescription": "追加しましょう!", "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "セットアップの手順を表示", - "xpack.infra.homePage.noMetricsIndicesTitle": "メトリックインデックスがないようです。", "xpack.infra.homePage.settingsTabTitle": "設定", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "インフラストラクチャデータを検索…(例:host.name:host-1)", "xpack.infra.homePage.toolbar.showingLastOneMinuteDataText": "指定期間のデータの最後の{duration}", @@ -20736,7 +20709,6 @@ "xpack.securitySolution.certificate.fingerprint.serverCertLabel": "サーバー証明書", "xpack.securitySolution.chart.dataAllValuesZerosTitle": "すべての値はゼロを返します", "xpack.securitySolution.chart.dataNotAvailableTitle": "チャートデータが利用できません", - "xpack.securitySolution.chrome.help.appName": "セキュリティ", "xpack.securitySolution.chrome.helpMenu.documentation": "セキュリティドキュメント", "xpack.securitySolution.chrome.helpMenu.documentation.ecs": "ECSドキュメンテーション", "xpack.securitySolution.clipboard.copied": "コピー完了", @@ -21854,7 +21826,6 @@ "xpack.securitySolution.editDataProvider.valueLabel": "値", "xpack.securitySolution.editDataProvider.valuePlaceholder": "値", "xpack.securitySolution.effectedPolicySelect.viewPolicyLinkLabel": "ポリシーを表示", - "xpack.securitySolution.emptyMessage": "Elastic Securityは無料かつオープンのElastic SIEMに、Endpoint Securityを搭載。脅威の防御と検知、脅威への対応を支援します。開始するには、セキュリティソリューション関連データをElastic Stackに追加する必要があります。詳細については、以下をご覧ください ", "xpack.securitySolution.emptyString.emptyStringDescription": "空の文字列", "xpack.securitySolution.endpoint.actions.agentDetails": "エージェント詳細を表示", "xpack.securitySolution.endpoint.actions.agentPolicy": "エージェントポリシーを表示", @@ -22819,7 +22790,6 @@ "xpack.securitySolution.overview.packetBeatFlowTitle": "フロー", "xpack.securitySolution.overview.packetbeatTLSTitle": "TLS", "xpack.securitySolution.overview.pageSubtitle": "Elastic Stackによるセキュリティ情報とイベント管理", - "xpack.securitySolution.overview.pageTitle": "セキュリティ", "xpack.securitySolution.overview.recentCasesSidebarTitle": "最近のケース", "xpack.securitySolution.overview.recentTimelinesSidebarTitle": "最近のタイムライン", "xpack.securitySolution.overview.showTopTooltip": "上位の{fieldName}を表示", @@ -22843,7 +22813,6 @@ "xpack.securitySolution.pages.common.emptyActionEndpoint": "Endpoint Securityを追加", "xpack.securitySolution.pages.common.emptyActionEndpointDescription": "脅威防御、検出、深いセキュリティデータの可視化を実現し、ホストを保護します。", "xpack.securitySolution.pages.common.emptyActionSecondary": "入門ガイドを表示します。", - "xpack.securitySolution.pages.common.emptyTitle": "Elastic Securityへようこそ。始めましょう。", "xpack.securitySolution.pages.fourohfour.pageNotFoundDescription": "ページが見つかりません", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "ページごとの行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "表示中", @@ -22957,7 +22926,6 @@ "xpack.securitySolution.search.timeline.templates": "テンプレート", "xpack.securitySolution.search.timelines": "タイムライン", "xpack.securitySolution.search.ueba": "ユーザーとエンティティ", - "xpack.securitySolution.security.title": "セキュリティ", "xpack.securitySolution.source.destination.packetsLabel": "パケット", "xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "クエリプレビュータイムフレーム選択", "xpack.securitySolution.stepDefineRule.previewQueryButton": "結果を表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 87c96d1efe48d..f07bd504f37cd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1669,12 +1669,6 @@ "data.functions.esaggs.help": "运行 AggConfig 聚合", "data.functions.esaggs.inspector.dataRequest.description": "此请求查询 Elasticsearch,以获取可视化的数据。", "data.functions.esaggs.inspector.dataRequest.title": "数据", - "dataViews.indexPatternLoad.help": "加载索引模式", - "dataViews.functions.indexPatternLoad.id.help": "要加载的索引模式 id", - "dataViews.ensureDefaultIndexPattern.bannerLabel": "要在 Kibana 中可视化和浏览数据,必须创建索引模式,以从 Elasticsearch 中检索数据。", - "dataViews.fetchFieldErrorTitle": "提取索引模式 {title} (ID: {id}) 的字段时出错", - "dataViews.indexPatternLoad.error.kibanaRequest": "在服务器上执行此搜索时需要 Kibana 请求。请向表达式执行模式参数提供请求对象。", - "dataViews.unableWriteLabel": "无法写入索引模式!请刷新页面以获取此索引模式的最新更改。", "data.inspector.table..dataDescriptionTooltip": "查看可视化后面的数据", "data.inspector.table.dataTitle": "数据", "data.inspector.table.downloadCSVToggleButtonLabel": "下载 CSV", @@ -2319,6 +2313,12 @@ "data.searchSessions.sessionService.sessionObjectFetchError": "无法提取搜索会话信息", "data.triggers.applyFilterDescription": "应用 kibana 筛选时。可能是单个值或范围筛选。", "data.triggers.applyFilterTitle": "应用筛选", + "dataViews.indexPatternLoad.help": "加载索引模式", + "dataViews.functions.indexPatternLoad.id.help": "要加载的索引模式 id", + "dataViews.ensureDefaultIndexPattern.bannerLabel": "要在 Kibana 中可视化和浏览数据,必须创建索引模式,以从 Elasticsearch 中检索数据。", + "dataViews.fetchFieldErrorTitle": "提取索引模式 {title} (ID: {id}) 的字段时出错", + "dataViews.indexPatternLoad.error.kibanaRequest": "在服务器上执行此搜索时需要 Kibana 请求。请向表达式执行模式参数提供请求对象。", + "dataViews.unableWriteLabel": "无法写入索引模式!请刷新页面以获取此索引模式的最新更改。", "devTools.badge.readOnly.text": "只读", "devTools.badge.readOnly.tooltip": "无法保存", "devTools.devToolsTitle": "开发工具", @@ -6359,9 +6359,6 @@ "xpack.apm.exactTransactionRateLabel": "{value} { unit, select, minute {tpm} other {tps} }", "xpack.apm.failedTransactionsCorrelations.licenseCheckText": "要使用失败事务相关性功能,必须订阅 Elastic 白金级许可证。", "xpack.apm.featureRegistry.apmFeatureName": "APM 和用户体验", - "xpack.apm.featureRegistry.manageAlertsName": "告警", - "xpack.apm.featureRegistry.subfeature.alertsAllName": "全部", - "xpack.apm.featureRegistry.subfeature.alertsReadName": "读取", "xpack.apm.feedbackMenu.appName": "APM", "xpack.apm.fetcher.error.status": "错误", "xpack.apm.fetcher.error.title": "提取资源时出错", @@ -6483,24 +6480,6 @@ "xpack.apm.localFilters.titles.serviceName": "服务名称", "xpack.apm.localFilters.titles.transactionUrl": "URL", "xpack.apm.localFiltersTitle": "筛选", - "xpack.apm.metadataTable.section.agentLabel": "代理", - "xpack.apm.metadataTable.section.clientLabel": "客户端", - "xpack.apm.metadataTable.section.containerLabel": "容器", - "xpack.apm.metadataTable.section.customLabel": "定制", - "xpack.apm.metadataTable.section.errorLabel": "错误", - "xpack.apm.metadataTable.section.hostLabel": "主机", - "xpack.apm.metadataTable.section.httpLabel": "HTTP", - "xpack.apm.metadataTable.section.labelsLabel": "标签", - "xpack.apm.metadataTable.section.messageLabel": "消息", - "xpack.apm.metadataTable.section.pageLabel": "页", - "xpack.apm.metadataTable.section.processLabel": "进程", - "xpack.apm.metadataTable.section.serviceLabel": "服务", - "xpack.apm.metadataTable.section.spanLabel": "跨度", - "xpack.apm.metadataTable.section.traceLabel": "跟踪", - "xpack.apm.metadataTable.section.transactionLabel": "事务", - "xpack.apm.metadataTable.section.urlLabel": "URL", - "xpack.apm.metadataTable.section.userAgentLabel": "用户代理", - "xpack.apm.metadataTable.section.userLabel": "用户", "xpack.apm.metrics.transactionChart.machineLearningLabel": "Machine Learning", "xpack.apm.metrics.transactionChart.machineLearningTooltip": "流显示平均延迟的预期边界。红色垂直标注表示异常分数等于或大于 75 的异常。", "xpack.apm.metrics.transactionChart.machineLearningTooltip.withKuery": "使用搜索栏筛选时,Machine Learning 结果处于隐藏状态", @@ -9438,11 +9417,9 @@ "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryButtonLabel": "管理查询", "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryDescription": "编辑、添加或移除此策展的查询。", "xpack.enterpriseSearch.appSearch.engine.curations.manageQueryTitle": "管理查询", - "xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.emptyDescription": "没有要显示的有机结果。在上面添加或更改活动查询。", "xpack.enterpriseSearch.appSearch.engine.curations.organicDocuments.title": "“{currentQuery}”的排名靠前有机文档", "xpack.enterpriseSearch.appSearch.engine.curations.overview.title": "已策展结果", "xpack.enterpriseSearch.appSearch.engine.curations.promoteButtonLabel": "提升此结果", - "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.description": "提升结果显示在有机结果之前。可以重新排列文档。", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.emptyDescription": "使用星号标记来自下面有机结果的文档或手动搜索或提升结果。", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.removeAllButtonLabel": "全部降低", "xpack.enterpriseSearch.appSearch.engine.curations.promotedDocuments.title": "提升文档", @@ -10073,11 +10050,9 @@ "xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.unsaved.message": "您的显示设置尚未保存。是否确定要离开?", "xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.visibleFields.title": "可见的字段", "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementContentExtractionLabel": "同步所有文本和内容", - "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementDescription": "为此源启用和禁用特定内容的提取。", "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementGlobalConfigLabel": "同步缩略图 - 已在全局配置级别禁用", "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementSynchronizeLabel": "同步此源", "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementThumbnailsLabel": "同步缩略图", - "xpack.enterpriseSearch.workplaceSearch.contentSources.syncManagementTitle": "同步管理", "xpack.enterpriseSearch.workplaceSearch.copyText": "复制", "xpack.enterpriseSearch.workplaceSearch.credentials.description": "在您的客户端中使用以下凭据从我们的身份验证服务器请求访问令牌。", "xpack.enterpriseSearch.workplaceSearch.credentials.title": "凭据", @@ -12895,9 +12870,7 @@ "xpack.infra.homePage.documentTitle": "指标", "xpack.infra.homePage.inventoryTabTitle": "库存", "xpack.infra.homePage.metricsExplorerTabTitle": "指标浏览器", - "xpack.infra.homePage.noMetricsIndicesDescription": "让我们添加一些!", "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "查看设置说明", - "xpack.infra.homePage.noMetricsIndicesTitle": "似乎您没有任何指标索引。", "xpack.infra.homePage.settingsTabTitle": "设置", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "搜索基础设施数据……(例如 host.name:host-1)", "xpack.infra.homePage.toolbar.showingLastOneMinuteDataText": "选定时间过去 {duration}的数据", @@ -21045,7 +21018,6 @@ "xpack.securitySolution.certificate.fingerprint.serverCertLabel": "服务器证书", "xpack.securitySolution.chart.dataAllValuesZerosTitle": "所有值返回了零", "xpack.securitySolution.chart.dataNotAvailableTitle": "图表数据不可用", - "xpack.securitySolution.chrome.help.appName": "安全", "xpack.securitySolution.chrome.helpMenu.documentation": "Security 文档", "xpack.securitySolution.chrome.helpMenu.documentation.ecs": "ECS 文档", "xpack.securitySolution.clipboard.copied": "已复制", @@ -22195,7 +22167,6 @@ "xpack.securitySolution.editDataProvider.valueLabel": "值", "xpack.securitySolution.editDataProvider.valuePlaceholder": "值", "xpack.securitySolution.effectedPolicySelect.viewPolicyLinkLabel": "查看策略", - "xpack.securitySolution.emptyMessage": "Elastic Security 将免费开放的 Elastic SIEM 和 Endpoint Security 相集成,以预防、检测并响应威胁。首先,您需要将安全解决方案相关数据添加到 Elastic Stack。有关更多信息,您可以查看我们的 ", "xpack.securitySolution.emptyString.emptyStringDescription": "空字符串", "xpack.securitySolution.endpoint.actions.agentDetails": "查看代理详情", "xpack.securitySolution.endpoint.actions.agentPolicy": "查看代理策略", @@ -23193,7 +23164,6 @@ "xpack.securitySolution.overview.packetBeatFlowTitle": "流", "xpack.securitySolution.overview.packetbeatTLSTitle": "TLS", "xpack.securitySolution.overview.pageSubtitle": "Elastic Stack 的安全信息和事件管理功能", - "xpack.securitySolution.overview.pageTitle": "安全", "xpack.securitySolution.overview.recentCasesSidebarTitle": "最近案例", "xpack.securitySolution.overview.recentTimelinesSidebarTitle": "最近的时间线", "xpack.securitySolution.overview.showTopTooltip": "显示排名靠前的{fieldName}", @@ -23217,7 +23187,6 @@ "xpack.securitySolution.pages.common.emptyActionEndpoint": "添加 Endpoint Security", "xpack.securitySolution.pages.common.emptyActionEndpointDescription": "使用威胁防御、检测和深度安全数据可见性功能保护您的主机。", "xpack.securitySolution.pages.common.emptyActionSecondary": "入门指南。", - "xpack.securitySolution.pages.common.emptyTitle": "欢迎使用 Elastic Security。让我们帮您如何入门。", "xpack.securitySolution.pages.common.updateAlertStatusFailed": "无法更新{ conflicts } 个{conflicts, plural, other {告警}}。", "xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed": "{ updated } 个{updated, plural, other {告警}}已成功更新,但是 { conflicts } 个无法更新,\n 因为{ conflicts, plural, other {其}}已被修改。", "xpack.securitySolution.pages.fourohfour.pageNotFoundDescription": "未找到页面", @@ -23333,7 +23302,6 @@ "xpack.securitySolution.search.timeline.templates": "模板", "xpack.securitySolution.search.timelines": "时间线", "xpack.securitySolution.search.ueba": "用户和实体", - "xpack.securitySolution.security.title": "安全", "xpack.securitySolution.source.destination.packetsLabel": "pkts", "xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "查询预览时间范围选择", "xpack.securitySolution.stepDefineRule.previewQueryButton": "预览结果", diff --git a/x-pack/plugins/triggers_actions_ui/server/index.test.ts b/x-pack/plugins/triggers_actions_ui/server/index.test.ts deleted file mode 100644 index 1149843d85a50..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/server/index.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { config } from './index'; -import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; - -const CONFIG_PATH = 'xpack.trigger_actions_ui'; -const applyStackAlertDeprecations = (settings: Record<string, unknown> = {}) => { - const deprecations = config.deprecations!(configDeprecationFactory); - const deprecationMessages: string[] = []; - const _config = { - [CONFIG_PATH]: settings, - }; - const { config: migrated } = applyDeprecations( - _config, - deprecations.map((deprecation) => ({ - deprecation, - path: CONFIG_PATH, - })), - () => - ({ message }) => - deprecationMessages.push(message) - ); - return { - messages: deprecationMessages, - migrated, - }; -}; - -describe('index', () => { - describe('deprecations', () => { - it('should deprecate .enabled flag', () => { - const { messages } = applyStackAlertDeprecations({ enabled: false }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "\\"xpack.trigger_actions_ui.enabled\\" is deprecated. The ability to disable this plugin will be removed in 8.0.0.", - ] - `); - }); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/server/index.ts b/x-pack/plugins/triggers_actions_ui/server/index.ts index c7d363af45247..89c17ea0d4189 100644 --- a/x-pack/plugins/triggers_actions_ui/server/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/index.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { get } from 'lodash'; import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server'; import { configSchema, ConfigSchema } from '../config'; import { TriggersActionsPlugin } from './plugin'; @@ -26,19 +25,6 @@ export const config: PluginConfigDescriptor<ConfigSchema> = { enableGeoTrackingThresholdAlert: true, }, schema: configSchema, - deprecations: () => [ - (settings, fromPath, addDeprecation) => { - const triggersActionsUi = get(settings, fromPath); - if (triggersActionsUi?.enabled === false || triggersActionsUi?.enabled === true) { - addDeprecation({ - message: `"xpack.trigger_actions_ui.enabled" is deprecated. The ability to disable this plugin will be removed in 8.0.0.`, - correctiveActions: { - manualSteps: [`Remove "xpack.trigger_actions_ui.enabled" from your kibana configs.`], - }, - }); - } - }, - ], }; export const plugin = (ctx: PluginInitializerContext) => new TriggersActionsPlugin(ctx); diff --git a/x-pack/plugins/uptime/e2e/journeys/uptime.journey.ts b/x-pack/plugins/uptime/e2e/journeys/uptime.journey.ts index 7407a1433ffaa..59a289ef21e7b 100644 --- a/x-pack/plugins/uptime/e2e/journeys/uptime.journey.ts +++ b/x-pack/plugins/uptime/e2e/journeys/uptime.journey.ts @@ -67,8 +67,4 @@ journey('uptime', ({ page, params }) => { step('Click on my monitor', async () => { await page.click('[data-test-subj=monitor-page-link-0001-up]'); }); - - step('Navigates to details page', async () => { - await page.click('[data-test-subj=uptimeMonitorPage]'); - }); }); diff --git a/x-pack/plugins/uptime/e2e/playwright_start.ts b/x-pack/plugins/uptime/e2e/playwright_start.ts index 99ceb591b90c8..aedb255b058be 100644 --- a/x-pack/plugins/uptime/e2e/playwright_start.ts +++ b/x-pack/plugins/uptime/e2e/playwright_start.ts @@ -42,7 +42,7 @@ async function playwrightStart(getService: any) { const res = await playwrightRun({ params: { kibanaUrl }, - playwrightOptions: { chromiumSandbox: false }, + playwrightOptions: { chromiumSandbox: false, timeout: 60 * 1000 }, }); console.log('Removing esArchiver...'); diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index e7fcb4607a8e4..3124324d90389 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -38,4 +38,4 @@ "githubTeam": "uptime" }, "description": "This plugin visualizes data from Synthetics and Heartbeat, and integrates with other Observability solutions." -} \ No newline at end of file +} diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index f82a312ef91f5..73f228f621540 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -126,11 +126,9 @@ const Application = (props: UptimeAppProps) => { className={APP_WRAPPER_CLASS} application={core.application} > - <main> - <UptimeAlertsFlyoutWrapper /> - <PageRouter /> - <ActionMenu appMountParameters={appMountParameters} /> - </main> + <UptimeAlertsFlyoutWrapper /> + <PageRouter /> + <ActionMenu appMountParameters={appMountParameters} /> </RedirectAppLinks> </div> </UptimeIndexPatternContextProvider> diff --git a/x-pack/plugins/uptime/public/apps/uptime_page_template.tsx b/x-pack/plugins/uptime/public/apps/uptime_page_template.tsx index 033fdcb61b28b..817bbf9bedcb1 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_page_template.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_page_template.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { EuiPageHeaderProps } from '@elastic/eui'; -import { OVERVIEW_ROUTE } from '../../common/constants'; +import { CERTIFICATES_ROUTE, OVERVIEW_ROUTE } from '../../common/constants'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { ClientPluginsStart } from './plugin'; import { useNoDataConfig } from './use_no_data_config'; @@ -44,14 +44,16 @@ export const UptimePageTemplateComponent: React.FC<Props> = ({ path, pageHeader, return <EmptyStateError errors={[error]} />; } - const showLoading = loading && path === OVERVIEW_ROUTE && !data; + const isMainRoute = path === OVERVIEW_ROUTE || path === CERTIFICATES_ROUTE; + + const showLoading = loading && isMainRoute && !data; return ( <> - <div data-test-subj={noDataConfig ? 'data-missing' : undefined} /> <StyledPageTemplateComponent pageHeader={pageHeader} - noDataConfig={path === OVERVIEW_ROUTE && !loading ? noDataConfig : undefined} + data-test-subj={noDataConfig ? 'data-missing' : undefined} + noDataConfig={isMainRoute && !loading ? noDataConfig : undefined} > {showLoading && <EmptyStateLoading />} <div diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx index 478edb563df9a..c1e32613a2ffb 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx @@ -41,6 +41,7 @@ const showMLJobNotification = ( basePath: string, range: { to: string; from: string }, success: boolean, + awaitingNodeAssignment: boolean, error?: Error ) => { if (success) { @@ -51,7 +52,9 @@ const showMLJobNotification = ( ), text: toMountPoint( <p> - {labels.JOB_CREATED_SUCCESS_MESSAGE} + {awaitingNodeAssignment + ? labels.JOB_CREATED_LAZY_SUCCESS_MESSAGE + : labels.JOB_CREATED_SUCCESS_MESSAGE} <MLJobLink monitorId={monitorId} basePath={basePath} dateRange={range}> {labels.VIEW_JOB} </MLJobLink> @@ -107,7 +110,8 @@ export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => { monitorId as string, basePath, { to: dateRangeEnd, from: dateRangeStart }, - true + true, + hasMLJob.awaitingNodeAssignment ); const loadMLJob = (jobId: string) => dispatch(getExistingMLJobAction.get({ monitorId: monitorId as string })); @@ -123,6 +127,7 @@ export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => { basePath, { to: dateRangeEnd, from: dateRangeStart }, false, + false, error as Error ); } diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx index 82b4006246ec7..1fc4093a67d83 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx @@ -22,6 +22,14 @@ export const JOB_CREATED_SUCCESS_MESSAGE = i18n.translate( } ); +export const JOB_CREATED_LAZY_SUCCESS_MESSAGE = i18n.translate( + 'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreatedLazyNotificationText', + { + defaultMessage: + 'The analysis is waiting for an ML node to become available. It might take a while before results are added to the response times graph.', + } +); + export const JOB_CREATION_FAILED = i18n.translate( 'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle', { diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx index 0da6f034e53bb..9f2e5d609e867 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx @@ -142,7 +142,7 @@ export const StepsList = ({ data, error, loading }: Props) => { return ( <> - <EuiTitle> + <EuiTitle size="s"> <h2> {statusMessage( steps.reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }), diff --git a/x-pack/plugins/uptime/public/state/actions/types.ts b/x-pack/plugins/uptime/public/state/actions/types.ts index b830f81624046..754710db306e4 100644 --- a/x-pack/plugins/uptime/public/state/actions/types.ts +++ b/x-pack/plugins/uptime/public/state/actions/types.ts @@ -48,6 +48,7 @@ export interface MonitorDetailsActionPayload { export interface CreateMLJobSuccess { count: number; jobId: string; + awaitingNodeAssignment: boolean; } export interface DeleteJobResults { diff --git a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts index 95784467610fb..24f2d667323d1 100644 --- a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts @@ -57,10 +57,12 @@ export const createMLJob = async ({ const response: DataRecognizerConfigResponse = await apiService.post(url, data); if (response?.jobs?.[0]?.id === getMLJobId(monitorId)) { const jobResponse = response.jobs[0]; + const datafeedResponse = response.datafeeds[0]; if (jobResponse.success) { return { count: 1, jobId: jobResponse.id, + awaitingNodeAssignment: datafeedResponse.awaitingMlNodeAllocation === true, }; } else { const { error } = jobResponse; diff --git a/x-pack/test/accessibility/apps/ingest_node_pipelines.ts b/x-pack/test/accessibility/apps/ingest_node_pipelines.ts index b9bc216db60b5..dab9c86bf018e 100644 --- a/x-pack/test/accessibility/apps/ingest_node_pipelines.ts +++ b/x-pack/test/accessibility/apps/ingest_node_pipelines.ts @@ -14,14 +14,14 @@ export default function ({ getService, getPageObjects }: any) { const log = getService('log'); const a11y = getService('a11y'); /* this is the wrapping service around axe */ - describe('Ingest Node Pipelines', async () => { + describe('Ingest Pipelines', async () => { before(async () => { await putSamplePipeline(esClient); await common.navigateToApp('ingestPipelines'); }); it('List View', async () => { - await retry.waitFor('Ingest Node Pipelines page to be visible', async () => { + await retry.waitFor('Ingest Pipelines page to be visible', async () => { await common.navigateToApp('ingestPipelines'); return testSubjects.exists('pipelineDetailsLink') ? true : false; }); diff --git a/x-pack/test/accessibility/apps/login_page.ts b/x-pack/test/accessibility/apps/login_page.ts index 580df3e4ccc88..8de4a47e10b1e 100644 --- a/x-pack/test/accessibility/apps/login_page.ts +++ b/x-pack/test/accessibility/apps/login_page.ts @@ -14,7 +14,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const PageObjects = getPageObjects(['common', 'security']); - describe('Security', () => { + // Failing: See https://github.com/elastic/kibana/issues/96372 + describe.skip('Security', () => { describe('Login Page', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts index 54bd29ede5865..d36c573e14e1f 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts @@ -8,7 +8,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('Ingest Node Pipelines', () => { + describe('Ingest Pipelines', () => { loadTestFile(require.resolve('./ingest_pipelines')); }); } diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts index 1ad2a05d4f783..80790a6df400f 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts @@ -145,7 +145,7 @@ export default function ({ getService }: FtrProviderContext) { await createPipeline({ body: PIPELINE, id: PIPELINE_ID }, true); } catch (err) { // eslint-disable-next-line no-console - console.log('[Setup error] Error creating ingest node pipeline'); + console.log('[Setup error] Error creating ingest pipeline'); throw err; } }); @@ -225,7 +225,7 @@ export default function ({ getService }: FtrProviderContext) { await createPipeline({ body: PIPELINE, id: PIPELINE_ID }, true); } catch (err) { // eslint-disable-next-line no-console - console.log('[Setup error] Error creating ingest node pipeline'); + console.log('[Setup error] Error creating ingest pipeline'); throw err; } }); diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 958c44df35757..762fc1642a87a 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -37,7 +37,7 @@ export default function ({ getService }: FtrProviderContext) { securitySolutionCases: ['all', 'read'], infrastructure: ['all', 'read'], logs: ['all', 'read'], - apm: ['all', 'read', 'minimal_all', 'minimal_read', 'alerts_all', 'alerts_read'], + apm: ['all', 'read'], discover: [ 'all', 'read', diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index d402a74287f98..efe159b36e3d3 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -37,6 +37,10 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./correlations/latency')); }); + describe('metadata/event_metadata', function () { + loadTestFile(require.resolve('./metadata/event_metadata')); + }); + describe('metrics_charts/metrics_charts', function () { loadTestFile(require.resolve('./metrics_charts/metrics_charts')); }); diff --git a/x-pack/test/apm_api_integration/tests/metadata/event_metadata.ts b/x-pack/test/apm_api_integration/tests/metadata/event_metadata.ts new file mode 100644 index 0000000000000..d979f0bad1ec6 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/metadata/event_metadata.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { PROCESSOR_EVENT } from '../../../../plugins/apm/common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../../plugins/apm/common/processor_event'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { registry } from '../../common/registry'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + const esClient = getService('es'); + + async function getLastDocId(processorEvent: ProcessorEvent) { + const response = await esClient.search<{ + [key: string]: { id: string }; + }>({ + index: ['apm-*'], + body: { + query: { + bool: { + filter: [{ term: { [PROCESSOR_EVENT]: processorEvent } }], + }, + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + }, + }); + + return response.body.hits.hits[0]._source![processorEvent].id; + } + + registry.when('Event metadata', { config: 'basic', archives: ['apm_8.0.0'] }, () => { + it('fetches transaction metadata', async () => { + const id = await getLastDocId(ProcessorEvent.transaction); + + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /api/apm/event_metadata/{processorEvent}/{id}', + params: { + path: { + processorEvent: ProcessorEvent.transaction, + id, + }, + }, + }); + + expect(body).keys('metadata').ok(); + + expect( + Object.keys(body.metadata).filter((key) => { + return Array.isArray(body.metadata[key]); + }) + ); + + expect(body.metadata).keys( + '@timestamp', + 'agent.name', + 'transaction.name', + 'transaction.type', + 'service.name' + ); + }); + + it('fetches error metadata', async () => { + const id = await getLastDocId(ProcessorEvent.error); + + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /api/apm/event_metadata/{processorEvent}/{id}', + params: { + path: { + processorEvent: ProcessorEvent.error, + id, + }, + }, + }); + + expect(body).keys('metadata').ok(); + + expect( + Object.keys(body.metadata).filter((key) => { + return Array.isArray(body.metadata[key]); + }) + ); + + expect(body.metadata).keys( + '@timestamp', + 'agent.name', + 'error.grouping_key', + 'error.grouping_name', + 'service.name' + ); + }); + + it('fetches span metadata', async () => { + const id = await getLastDocId(ProcessorEvent.span); + + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /api/apm/event_metadata/{processorEvent}/{id}', + params: { + path: { + processorEvent: ProcessorEvent.span, + id, + }, + }, + }); + + expect(body).keys('metadata').ok(); + + expect( + Object.keys(body.metadata).filter((key) => { + return Array.isArray(body.metadata[key]); + }) + ); + + expect(body.metadata).keys( + '@timestamp', + 'agent.name', + 'span.name', + 'span.type', + 'service.name' + ); + }); + }); +} diff --git a/x-pack/test/banners_functional/config.ts b/x-pack/test/banners_functional/config.ts index c9acff91aecd1..03f91dfbc34e2 100644 --- a/x-pack/test/banners_functional/config.ts +++ b/x-pack/test/banners_functional/config.ts @@ -32,7 +32,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...kibanaFunctionalConfig.get('kbnTestServer'), serverArgs: [ ...kibanaFunctionalConfig.get('kbnTestServer.serverArgs'), - '--xpack.banners.placement=header', + '--xpack.banners.placement=top', '--xpack.banners.textContent="global banner text"', ], }, diff --git a/x-pack/test/case_api_integration/common/config.ts b/x-pack/test/case_api_integration/common/config.ts index 514b54982ee42..e6fda129eaa16 100644 --- a/x-pack/test/case_api_integration/common/config.ts +++ b/x-pack/test/case_api_integration/common/config.ts @@ -140,7 +140,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) (pluginDir) => `--plugin-path=${path.resolve(__dirname, 'fixtures', 'plugins', pluginDir)}` ), - `--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, + `--server.xsrf.allowlist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, ...(ssl ? [ `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, diff --git a/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap b/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap index 8f9428d8a12db..8e06e62385315 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap +++ b/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap @@ -276,6 +276,7 @@ Object { "type": "image/svg+xml", }, ], + "keepPoliciesUpToDate": false, "license": "basic", "name": "apache", "owner": Object { @@ -449,6 +450,7 @@ Object { }, ], "internal": false, + "keep_policies_up_to_date": false, "name": "apache", "package_assets": Array [ Object { diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index 348b4bef59b30..e57899531e939 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -618,6 +618,7 @@ const expectAssetsInstalled = ({ install_status: 'installed', install_started_at: res.attributes.install_started_at, install_source: 'registry', + keep_policies_up_to_date: false, }); }); }; diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 390be9bf6ea19..3516eccf9bb15 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -432,6 +432,7 @@ export default function (providerContext: FtrProviderContext) { install_status: 'installed', install_started_at: res.attributes.install_started_at, install_source: 'registry', + keep_policies_up_to_date: false, }); }); }); diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/agent/stream/stream.yml.hbs b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/agent/stream/stream.yml.hbs new file mode 100644 index 0000000000000..2870385f21f95 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/agent/stream/stream.yml.hbs @@ -0,0 +1 @@ +config.version: "2" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/fields/fields.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/fields/fields.yml new file mode 100644 index 0000000000000..6e003ed0ad147 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/fields/fields.yml @@ -0,0 +1,16 @@ +- name: data_stream.type + type: constant_keyword + description: > + Data stream type. +- name: data_stream.dataset + type: constant_keyword + description: > + Data stream dataset. +- name: data_stream.namespace + type: constant_keyword + description: > + Data stream namespace. +- name: '@timestamp' + type: date + description: > + Event timestamp. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/manifest.yml new file mode 100644 index 0000000000000..95b72f0122aec --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/data_stream/test_stream/manifest.yml @@ -0,0 +1,15 @@ +title: Test stream +type: logs +streams: + - input: test_input + vars: + - name: test_var + type: text + title: Test Var + show_user: true + default: Test Value + - name: test_var_2 + type: text + title: Test Var 2 + show_user: true + default: Test Value 2 diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/docs/README.md new file mode 100644 index 0000000000000..0b9b18421c9dc --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/docs/README.md @@ -0,0 +1,3 @@ +# Test package + +This is a test package for testing automated upgrades for package policies diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/manifest.yml new file mode 100644 index 0000000000000..2105ee451ffae --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/package_policy_upgrade/0.2.5-non-breaking-change/manifest.yml @@ -0,0 +1,23 @@ +format_version: 1.0.0 +name: package_policy_upgrade +title: Tests package policy upgrades +description: This is a test package for upgrading package policies +version: 0.2.5-non-breaking-change +categories: [] +release: beta +type: integration +license: basic +requirement: + elasticsearch: + versions: '>7.7.0' + kibana: + versions: '>7.7.0' +policy_templates: + - name: package_policy_upgrade + title: Package Policy Upgrade + description: Test Package for Upgrading Package Policies + inputs: + - type: test_input + title: Test Input + description: Test Input + enabled: true diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts b/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts index 3a7d6f5d6b19e..0be2d7d0a7468 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts @@ -162,6 +162,122 @@ export default function (providerContext: FtrProviderContext) { }); }); + describe('when upgrading to a version with no breaking changes', function () { + withTestPackageVersion('0.2.5-non-breaking-change'); + + beforeEach(async function () { + const { body: agentPolicyResponse } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + }) + .expect(200); + + agentPolicyId = agentPolicyResponse.item.id; + + const { body: packagePolicyResponse } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'package_policy_upgrade_1', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [ + { + policy_template: 'package_policy_upgrade', + type: 'test_input', + enabled: true, + streams: [ + { + id: 'test-package_policy_upgrade-xxxx', + enabled: true, + data_stream: { + type: 'test_stream', + dataset: 'package_policy_upgrade.test_stream', + }, + vars: { + test_var: { + value: 'My custom test value', + }, + }, + }, + ], + }, + ], + package: { + name: 'package_policy_upgrade', + title: 'This is a test package for upgrading package policies', + version: '0.2.0-add-non-required-test-var', + }, + }) + .expect(200); + + packagePolicyId = packagePolicyResponse.item.id; + }); + + afterEach(async function () { + await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packagePolicyIds: [packagePolicyId] }) + .expect(200); + + await supertest + .post('/api/fleet/agent_policies/delete') + .set('kbn-xsrf', 'xxxx') + .send({ agentPolicyId }) + .expect(200); + }); + + describe('dry run', function () { + it('returns a valid diff', async function () { + const { body }: { body: UpgradePackagePolicyDryRunResponse } = await supertest + .post(`/api/fleet/package_policies/upgrade`) + .set('kbn-xsrf', 'xxxx') + .send({ + packagePolicyIds: [packagePolicyId], + dryRun: true, + }) + .expect(200); + + expect(body.length).to.be(1); + expect(body[0].diff?.length).to.be(2); + expect(body[0].hasErrors).to.be(false); + + const [currentPackagePolicy, proposedPackagePolicy] = body[0].diff ?? []; + + expect(currentPackagePolicy?.package?.version).to.be('0.2.0-add-non-required-test-var'); + expect(proposedPackagePolicy?.package?.version).to.be('0.2.5-non-breaking-change'); + + const testInput = proposedPackagePolicy?.inputs.find(({ type }) => type === 'test_input'); + const testStream = testInput?.streams[0]; + + expect(testStream?.vars?.test_var.value).to.be('My custom test value'); + }); + }); + + describe('upgrade', function () { + it('successfully upgrades package policy', async function () { + const { body }: { body: UpgradePackagePolicyResponse } = await supertest + .post(`/api/fleet/package_policies/upgrade`) + .set('kbn-xsrf', 'xxxx') + .send({ + packagePolicyIds: [packagePolicyId], + dryRun: false, + }) + .expect(200); + + expect(body.length).to.be(1); + expect(body[0].success).to.be(true); + }); + }); + }); + describe('when upgrading to a version where a non-required variable has been added', function () { withTestPackageVersion('0.2.0-add-non-required-test-var'); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts index c245b45917497..936dd49255205 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts @@ -28,12 +28,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); } - describe('spaces', () => { + // Failing: See https://github.com/elastic/kibana/issues/113067 + describe.skip('spaces', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); }); - describe('space with no features disabled', () => { + // FLAKY: https://github.com/elastic/kibana/issues/60559 + describe.skip('space with no features disabled', () => { before(async () => { // we need to load the following in every situation as deleting // a space deletes all of the associated saved objects diff --git a/x-pack/test/functional/apps/discover/saved_searches.ts b/x-pack/test/functional/apps/discover/saved_searches.ts index 1d8de9fe9fb6d..16deecde2b0ba 100644 --- a/x-pack/test/functional/apps/discover/saved_searches.ts +++ b/x-pack/test/functional/apps/discover/saved_searches.ts @@ -16,16 +16,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dataGrid = getService('dataGrid'); const panelActions = getService('dashboardPanelActions'); const panelActionsTimeRange = getService('dashboardPanelTimeRange'); + const queryBar = getService('queryBar'); + const filterBar = getService('filterBar'); const ecommerceSOPath = 'x-pack/test/functional/fixtures/kbn_archiver/reporting/ecommerce.json'; + const defaultSettings = { + defaultIndex: 'logstash-*', + 'doc_table:legacy': false, + }; - describe('Discover Saved Searches', () => { + const setTimeRange = async () => { + const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; + const toTime = 'Aug 23, 2019 @ 16:18:51.821'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + }; + + // Failing: See https://github.com/elastic/kibana/issues/104578 + describe.skip('Discover Saved Searches', () => { before('initialize tests', async () => { await esArchiver.load('x-pack/test/functional/es_archives/reporting/ecommerce'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); await kibanaServer.importExport.load(ecommerceSOPath); - await kibanaServer.uiSettings.update({ 'doc_table:legacy': false }); + await kibanaServer.uiSettings.update(defaultSettings); }); + after('clean up archives', async () => { await esArchiver.unload('x-pack/test/functional/es_archives/reporting/ecommerce'); + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); await kibanaServer.importExport.unload(ecommerceSOPath); await kibanaServer.uiSettings.unset('doc_table:legacy'); }); @@ -34,9 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should be possible to customize time range for saved searches on dashboards', async () => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); - const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; - const toTime = 'Aug 23, 2019 @ 16:18:51.821'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await setTimeRange(); await dashboardAddPanel.clickOpenAddPanel(); await dashboardAddPanel.addSavedSearch('Ecommerce Data'); expect(await dataGrid.getDocCount()).to.be(500); @@ -49,5 +63,35 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await dataGrid.hasNoResults()).to.be(true); }); }); + + it(`should unselect saved search when navigating to a 'new'`, async function () { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.selectIndexPattern('ecommerce'); + await setTimeRange(); + await filterBar.addFilter('category', 'is', `Men's Shoes`); + await queryBar.setQuery('customer_gender:MALE'); + + await PageObjects.discover.saveSearch('test-unselect-saved-search'); + + await queryBar.submitQuery(); + + expect(await filterBar.hasFilter('category', `Men's Shoes`)).to.be(true); + expect(await queryBar.getQueryString()).to.eql('customer_gender:MALE'); + + await PageObjects.discover.clickNewSearchButton(); + + expect(await filterBar.hasFilter('category', `Men's Shoes`)).to.be(false); + expect(await queryBar.getQueryString()).to.eql(''); + + await PageObjects.discover.selectIndexPattern('logstash-*'); + + expect(await filterBar.hasFilter('category', `Men's Shoes`)).to.be(false); + expect(await queryBar.getQueryString()).to.eql(''); + + await PageObjects.discover.selectIndexPattern('ecommerce'); + + expect(await filterBar.hasFilter('category', `Men's Shoes`)).to.be(false); + expect(await queryBar.getQueryString()).to.eql(''); + }); }); } diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts index 0118cfdafc2b3..65b9019f556fd 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts @@ -66,13 +66,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('infrastructure landing page without data', () => { - it(`shows 'Change source configuration' button`, async () => { + it('shows no data page', async () => { await PageObjects.common.navigateToUrlWithBrowserHistory('infraOps', '', undefined, { ensureCurrentUrl: true, shouldLoginIfPrompted: false, }); - await testSubjects.existOrFail('~infrastructureViewSetupInstructionsButton'); - await testSubjects.existOrFail('~configureSourceButton'); + await testSubjects.existOrFail('~noDataPage'); }); it(`doesn't show read-only badge`, async () => { @@ -164,13 +163,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('infrastructure landing page without data', () => { - it(`doesn't show 'Change source configuration' button`, async () => { + it('shows No data page', async () => { await PageObjects.common.navigateToUrlWithBrowserHistory('infraOps', '', undefined, { ensureCurrentUrl: true, shouldLoginIfPrompted: false, }); - await testSubjects.existOrFail('~infrastructureViewSetupInstructionsButton'); - await testSubjects.missingOrFail('~configureSourceButton'); + await testSubjects.existOrFail('~noDataPage'); }); it(`shows read-only badge`, async () => { diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts index 94886a89002b2..545eb08223693 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts @@ -51,7 +51,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('infraOps', { basePath: '/s/custom_space', }); - await testSubjects.existOrFail('~noMetricsIndicesPrompt'); + await testSubjects.existOrFail('~noDataPage'); }); }); @@ -118,7 +118,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('infraOps', { basePath: '/s/custom_space', }); - await testSubjects.existOrFail('~noMetricsIndicesPrompt'); + await testSubjects.existOrFail('~noDataPage'); }); }); @@ -143,7 +143,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('infraOps', { basePath: '/s/custom_space', }); - await testSubjects.existOrFail('~noMetricsIndicesPrompt'); + await testSubjects.existOrFail('~noDataPage'); }); }); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts index d5e22af657d6a..d120a6c582c7b 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts @@ -63,14 +63,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('logs landing page without data', () => { - it(`shows 'Change source configuration' button`, async () => { + it(`shows the 'No data' page`, async () => { await PageObjects.common.navigateToUrlWithBrowserHistory('infraLogs', '', undefined, { ensureCurrentUrl: true, shouldLoginIfPrompted: false, }); await testSubjects.existOrFail('~infraLogsPage'); - await testSubjects.existOrFail('~logsViewSetupInstructionsButton'); - await testSubjects.existOrFail('~configureSourceButton'); + await testSubjects.existOrFail('~noDataPage'); }); it(`doesn't show read-only badge`, async () => { @@ -126,14 +125,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('logs landing page without data', () => { - it(`doesn't show 'Change source configuration' button`, async () => { + it(`Shows the 'No data' page`, async () => { await PageObjects.common.navigateToUrlWithBrowserHistory('infraLogs', '', undefined, { ensureCurrentUrl: true, shouldLoginIfPrompted: false, }); await testSubjects.existOrFail('~infraLogsPage'); - await testSubjects.existOrFail('~logsViewSetupInstructionsButton'); - await testSubjects.missingOrFail('~configureSourceButton'); + await testSubjects.existOrFail('~noDataPage'); }); it(`shows read-only badge`, async () => { diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts index a67d7d1858af3..9c5e5667cf39c 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts @@ -43,15 +43,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('logs landing page without data', () => { - it(`shows 'Change source configuration' button`, async () => { + it(`shows 'No data' page`, async () => { await PageObjects.common.navigateToUrlWithBrowserHistory('infraLogs', '', undefined, { basePath: '/s/custom_space', ensureCurrentUrl: true, shouldLoginIfPrompted: false, }); await testSubjects.existOrFail('~infraLogsPage'); - await testSubjects.existOrFail('~logsViewSetupInstructionsButton'); - await testSubjects.existOrFail('~configureSourceButton'); + await testSubjects.existOrFail('~noDataPage'); }); }); }); diff --git a/x-pack/test/functional/apps/infra/log_entry_categories_tab.ts b/x-pack/test/functional/apps/infra/log_entry_categories_tab.ts index 33133e6306efe..11587845aae9c 100644 --- a/x-pack/test/functional/apps/infra/log_entry_categories_tab.ts +++ b/x-pack/test/functional/apps/infra/log_entry_categories_tab.ts @@ -10,6 +10,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); const logsUi = getService('logsUi'); const retry = getService('retry'); @@ -17,12 +18,22 @@ export default ({ getService }: FtrProviderContext) => { this.tags('includeFirefox'); describe('with a trial license', () => { - it('is visible', async () => { + it('Shows no data page when indices do not exist', async () => { + await logsUi.logEntryCategoriesPage.navigateTo(); + + await retry.try(async () => { + expect(await logsUi.logEntryCategoriesPage.getNoDataScreen()).to.be.ok(); + }); + }); + + it('shows setup page when indices exist', async () => { + await esArchiver.load('x-pack/test/functional/es_archives/infra/simple_logs'); await logsUi.logEntryCategoriesPage.navigateTo(); await retry.try(async () => { expect(await logsUi.logEntryCategoriesPage.getSetupScreen()).to.be.ok(); }); + await esArchiver.unload('x-pack/test/functional/es_archives/infra/simple_logs'); }); }); }); diff --git a/x-pack/test/functional/apps/infra/log_entry_rate_tab.ts b/x-pack/test/functional/apps/infra/log_entry_rate_tab.ts index 1635824529749..632b1e6917ca0 100644 --- a/x-pack/test/functional/apps/infra/log_entry_rate_tab.ts +++ b/x-pack/test/functional/apps/infra/log_entry_rate_tab.ts @@ -12,17 +12,28 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getService }: FtrProviderContext) => { const logsUi = getService('logsUi'); const retry = getService('retry'); + const esArchiver = getService('esArchiver'); describe('Log Entry Rate Tab', function () { this.tags('includeFirefox'); describe('with a trial license', () => { - it('is visible', async () => { + it('Shows no data page when indices do not exist', async () => { + await logsUi.logEntryRatePage.navigateTo(); + + await retry.try(async () => { + expect(await logsUi.logEntryRatePage.getNoDataScreen()).to.be.ok(); + }); + }); + + it('shows setup page when indices exist', async () => { + await esArchiver.load('x-pack/test/functional/es_archives/infra/simple_logs'); await logsUi.logEntryRatePage.navigateTo(); await retry.try(async () => { expect(await logsUi.logEntryRatePage.getSetupScreen()).to.be.ok(); }); + await esArchiver.unload('x-pack/test/functional/es_archives/infra/simple_logs'); }); }); }); diff --git a/x-pack/test/functional/apps/infra/logs_source_configuration.ts b/x-pack/test/functional/apps/infra/logs_source_configuration.ts index b84109637885c..dcbe30864640b 100644 --- a/x-pack/test/functional/apps/infra/logs_source_configuration.ts +++ b/x-pack/test/functional/apps/infra/logs_source_configuration.ts @@ -60,7 +60,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await logsUi.logStreamPage.navigateTo(); await retry.try(async () => { - await logsUi.logStreamPage.getNoLogsIndicesPrompt(); + await logsUi.logStreamPage.getNoDataPage(); }); }); diff --git a/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts index aed73d6c9858d..1f89ea8c635a6 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts @@ -60,7 +60,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should render the "Ingest" section with ingest pipelines', async () => { await PageObjects.common.navigateToApp('management'); const sections = await managementMenu.getSections(); - // We gave the ingest node pipelines user access to advanced settings to allow them to use ingest node pipelines. + // We gave the ingest pipelines user access to advanced settings to allow them to use ingest pipelines. // See https://github.com/elastic/kibana/pull/102409/ expect(sections).to.have.length(2); expect(sections[0]).to.eql({ diff --git a/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts index 17b4fef06f5ce..026cea52e8102 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts @@ -29,10 +29,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('Loads the app', async () => { - log.debug('Checking for section heading to say Ingest Node Pipelines.'); + log.debug('Checking for section heading to say Ingest Pipelines.'); const headingText = await pageObjects.ingestPipelines.sectionHeadingText(); - expect(headingText).to.be('Ingest Node Pipelines'); + expect(headingText).to.be('Ingest Pipelines'); }); it('Creates a pipeline', async () => { diff --git a/x-pack/test/functional/apps/lens/heatmap.ts b/x-pack/test/functional/apps/lens/heatmap.ts index ddc4130d388ce..deca06b6b351a 100644 --- a/x-pack/test/functional/apps/lens/heatmap.ts +++ b/x-pack/test/functional/apps/lens/heatmap.ts @@ -13,8 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const elasticChart = getService('elasticChart'); const testSubjects = getService('testSubjects'); - // FLAKY: https://github.com/elastic/kibana/issues/113043 - describe.skip('lens heatmap', () => { + describe('lens heatmap', () => { before(async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); @@ -74,8 +73,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.openPalettePanel('lnsHeatmap'); await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_0', '10', { clearWithKeyboard: true, + typeCharByChar: true, }); - await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.waitForVisualization(); const debugState = await PageObjects.lens.getCurrentChartDebugState(); diff --git a/x-pack/test/functional/apps/visualize/reporting.ts b/x-pack/test/functional/apps/visualize/reporting.ts index f08d242f4024f..efffa0b6a692b 100644 --- a/x-pack/test/functional/apps/visualize/reporting.ts +++ b/x-pack/test/functional/apps/visualize/reporting.ts @@ -25,7 +25,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'visEditor', ]); - describe('Visualize Reporting Screenshots', () => { + // Failing: See https://github.com/elastic/kibana/issues/113496 + describe.skip('Visualize Reporting Screenshots', () => { before('initialize tests', async () => { log.debug('ReportingPage:initTests'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/reporting/ecommerce'); diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json deleted file mode 100644 index b8994a05ea5cc..0000000000000 --- a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json +++ /dev/null @@ -1,226 +0,0 @@ -{ - "type": "doc", - "value": { - "id": "M92ScEJT9M9QusfIi3hpEb0AAAAAAAAA", - "index": "metrics-endpoint.metadata_current_default", - "source": { - "@timestamp": 1626897841950, - "Endpoint": { - "policy": { - "applied": { - "id": "00000000-0000-0000-0000-000000000000", - "name": "Default", - "status": "failure" - } - }, - "status": "enrolled", - "configuration": { - "isolation": false - }, - "state": { - "isolation": false - } - }, - "agent": { - "id": "3838df35-a095-4af4-8fce-0b6d78793f2e", - "name": "Elastic Endpoint", - "version": "6.8.0" - }, - "elastic": { - "agent": { - "id": "023fa40c-411d-4188-a941-4147bfadd095" - } - }, - "event": { - "action": "endpoint_metadata", - "category": [ - "host" - ], - "created": 1626897841950, - "dataset": "endpoint.metadata", - "id": "32f5fda2-48e4-4fae-b89e-a18038294d16", - "ingested": "2020-09-09T18:25:15.853783Z", - "kind": "metric", - "module": "endpoint", - "type": [ - "info" - ] - }, - "host": { - "hostname": "rezzani-7.example.com", - "id": "fc0ff548-feba-41b6-8367-65e8790d0eaf", - "ip": [ - "10.101.149.26", - "2606:a000:ffc0:39:11ef:37b9:3371:578c" - ], - "mac": [ - "e2-6d-f9-0-46-2e" - ], - "name": "rezzani-7.example.com", - "os": { - "Ext": { - "variant": "Windows Pro" - }, - "family": "Windows", - "full": "Windows 10", - "name": "windows 10.0", - "platform": "Windows", - "version": "10.0" - } - } - } - } -} - -{ - "type": "doc", - "value": { - "id": "OU3RgCJaNnR90byeDEHutp8AAAAAAAAA", - "index": "metrics-endpoint.metadata_current_default", - "source": { - "@timestamp": 1626897841950, - "Endpoint": { - "policy": { - "applied": { - "id": "C2A9093E-E289-4C0A-AA44-8C32A414FA7A", - "name": "Default", - "status": "failure" - } - }, - "status": "enrolled", - "configuration": { - "isolation": false - }, - "state": { - "isolation": false - } - }, - "agent": { - "id": "963b081e-60d1-482c-befd-a5815fa8290f", - "name": "Elastic Endpoint", - "version": "6.6.1" - }, - "elastic": { - "agent": { - "id": "11488bae-880b-4e7b-8d28-aac2aa9de816" - } - }, - "event": { - "action": "endpoint_metadata", - "category": [ - "host" - ], - "created": 1626897841950, - "dataset": "endpoint.metadata", - "id": "32f5fda2-48e4-4fae-b89e-a18038294d14", - "ingested": "2020-09-09T18:25:14.919526Z", - "kind": "metric", - "module": "endpoint", - "type": [ - "info" - ] - }, - "host": { - "architecture": "x86", - "hostname": "cadmann-4.example.com", - "id": "1fb3e58f-6ab0-4406-9d2a-91911207a712", - "ip": [ - "10.192.213.130", - "10.70.28.129" - ], - "mac": [ - "a9-71-6a-cc-93-85", - "f7-31-84-d3-21-68", - "2-95-12-39-ca-71" - ], - "name": "cadmann-4.example.com", - "os": { - "Ext": { - "variant": "Windows Pro" - }, - "family": "Windows", - "full": "Windows 10", - "name": "windows 10.0", - "platform": "Windows", - "version": "10.0" - } - } - } - } -} - -{ - "type": "doc", - "value": { - "id": "YjqDCEuI6JmLeLOSyZx_NhMAAAAAAAAA", - "index": "metrics-endpoint.metadata_current_default", - "source": { - "@timestamp": 1626897841950, - "Endpoint": { - "policy": { - "applied": { - "id": "C2A9093E-E289-4C0A-AA44-8C32A414FA7A", - "name": "Default", - "status": "success" - } - }, - "status": "enrolled", - "configuration": { - "isolation": false - }, - "state": { - "isolation": false - } - }, - "agent": { - "id": "b3412d6f-b022-4448-8fee-21cc936ea86b", - "name": "Elastic Endpoint", - "version": "6.0.0" - }, - "elastic": { - "agent": { - "id": "92ac1ce0-e1f7-409e-8af6-f17e97b1fc71" - } - }, - "event": { - "action": "endpoint_metadata", - "category": [ - "host" - ], - "created": 1626897841950, - "dataset": "endpoint.metadata", - "id": "32f5fda2-48e4-4fae-b89e-a18038294d15", - "ingested": "2020-09-09T18:25:15.853404Z", - "kind": "metric", - "module": "endpoint", - "type": [ - "info" - ] - }, - "host": { - "architecture": "x86_64", - "hostname": "thurlow-9.example.com", - "id": "2f735e3d-be14-483b-9822-bad06e9045ca", - "ip": [ - "10.46.229.234" - ], - "mac": [ - "30-8c-45-55-69-b8", - "e5-36-7e-8f-a3-84", - "39-a1-37-20-18-74" - ], - "name": "thurlow-9.example.com", - "os": { - "Ext": { - "variant": "Windows Server" - }, - "family": "Windows", - "full": "Windows Server 2016", - "name": "windows 10.0", - "platform": "Windows", - "version": "10.0" - } - } - } - } -} diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 0790c694e772c..726668e3b1b0a 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -141,7 +141,7 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide }, async getNoMetricsIndicesPrompt() { - return await testSubjects.find('noMetricsIndicesPrompt'); + return await testSubjects.find('noDataPage'); }, async getNoMetricsDataPrompt() { diff --git a/x-pack/test/functional/services/logs_ui/log_entry_categories.ts b/x-pack/test/functional/services/logs_ui/log_entry_categories.ts index b244360ce4ce4..0aec1cbea2210 100644 --- a/x-pack/test/functional/services/logs_ui/log_entry_categories.ts +++ b/x-pack/test/functional/services/logs_ui/log_entry_categories.ts @@ -17,6 +17,10 @@ export function LogEntryCategoriesPageProvider({ getPageObjects, getService }: F await pageObjects.infraLogs.navigateToTab('log-categories'); }, + async getNoDataScreen(): Promise<WebElementWrapper> { + return await testSubjects.find('noDataPage'); + }, + async getSetupScreen(): Promise<WebElementWrapper> { return await testSubjects.find('logEntryCategoriesSetupPage'); }, diff --git a/x-pack/test/functional/services/logs_ui/log_entry_rate.ts b/x-pack/test/functional/services/logs_ui/log_entry_rate.ts index e517fd76a06dc..6be84edeb1940 100644 --- a/x-pack/test/functional/services/logs_ui/log_entry_rate.ts +++ b/x-pack/test/functional/services/logs_ui/log_entry_rate.ts @@ -20,5 +20,9 @@ export function LogEntryRatePageProvider({ getPageObjects, getService }: FtrProv async getSetupScreen(): Promise<WebElementWrapper> { return await testSubjects.find('logEntryRateSetupPage'); }, + + async getNoDataScreen() { + return await testSubjects.find('noDataPage'); + }, }; } diff --git a/x-pack/test/functional/services/logs_ui/log_stream.ts b/x-pack/test/functional/services/logs_ui/log_stream.ts index 89afae57507d9..214290bd21ef4 100644 --- a/x-pack/test/functional/services/logs_ui/log_stream.ts +++ b/x-pack/test/functional/services/logs_ui/log_stream.ts @@ -46,5 +46,9 @@ export function LogStreamPageProvider({ getPageObjects, getService }: FtrProvide async getNoLogsIndicesPrompt() { return await testSubjects.find('noLogsIndicesPrompt'); }, + + async getNoDataPage() { + return await testSubjects.find('noDataPage'); + }, }; } diff --git a/x-pack/test/plugin_api_integration/config.ts b/x-pack/test/plugin_api_integration/config.ts index cd13186a69cc6..0a9535df5a9f3 100644 --- a/x-pack/test/plugin_api_integration/config.ts +++ b/x-pack/test/plugin_api_integration/config.ts @@ -38,7 +38,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...integrationConfig.get('kbnTestServer'), serverArgs: [ ...integrationConfig.get('kbnTestServer.serverArgs'), - '--xpack.eventLog.enabled=true', '--xpack.eventLog.logEntries=true', '--xpack.eventLog.indexEntries=true', '--xpack.task_manager.monitored_aggregated_stats_refresh_rate=5000', diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/server/init_routes.ts b/x-pack/test/plugin_api_integration/plugins/event_log/server/init_routes.ts index 5c27ffe62a48d..805feee159f27 100644 --- a/x-pack/test/plugin_api_integration/plugins/event_log/server/init_routes.ts +++ b/x-pack/test/plugin_api_integration/plugins/event_log/server/init_routes.ts @@ -179,7 +179,7 @@ export const isEventLogServiceEnabledRoute = ( res: KibanaResponseFactory ): Promise<IKibanaResponse<any>> { logger.info(`test if event logger is enabled`); - return res.ok({ body: { isEnabled: eventLogService.isEnabled() } }); + return res.ok({ body: { isEnabled: true } }); } ); }; diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts index 4c624cdbdda63..2c8564df02e0b 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -19,14 +19,6 @@ export default function ({ getService }: FtrProviderContext) { const retry = getService('retry'); describe('Event Log service API', () => { - it('should check if it is enabled', async () => { - const configValue = config - .get('kbnTestServer.serverArgs') - .find((val: string) => val === '--xpack.eventLog.enabled=true'); - const result = await isEventLogServiceEnabled(); - expect(configValue).to.be.eql(`--xpack.eventLog.enabled=${result.body.isEnabled}`); - }); - it('should check if logging entries is enabled', async () => { const configValue = config .get('kbnTestServer.serverArgs') @@ -216,14 +208,6 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); } - async function isEventLogServiceEnabled() { - log.debug(`isEventLogServiceEnabled`); - return await supertest - .get(`/api/log_event_fixture/isEventLogServiceEnabled`) - .set('kbn-xsrf', 'foo') - .expect(200); - } - async function isEventLogServiceLoggingEntries() { log.debug(`isEventLogServiceLoggingEntries`); return await supertest diff --git a/x-pack/test/rule_registry/common/config.ts b/x-pack/test/rule_registry/common/config.ts index 487af84141d20..9cce58c30f6e9 100644 --- a/x-pack/test/rule_registry/common/config.ts +++ b/x-pack/test/rule_registry/common/config.ts @@ -83,7 +83,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) // TO DO: Remove feature flags once we're good to go '--xpack.securitySolution.enableExperimental=["ruleRegistryEnabled"]', '--xpack.ruleRegistry.write.enabled=true', - `--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, + `--server.xsrf.allowlist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, ...(ssl ? [ `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, diff --git a/x-pack/test/security_solution_cypress/runner.ts b/x-pack/test/security_solution_cypress/runner.ts index f93e20ec382cd..4283b85af0c17 100644 --- a/x-pack/test/security_solution_cypress/runner.ts +++ b/x-pack/test/security_solution_cypress/runner.ts @@ -26,22 +26,10 @@ export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrPr cwd: resolve(__dirname, '../../plugins/security_solution'), env: { FORCE_COLOR: '1', - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_baseUrl: Url.format(config.get('servers.kibana')), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_protocol: config.get('servers.kibana.protocol'), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_hostname: config.get('servers.kibana.hostname'), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_configport: config.get('servers.kibana.port'), + CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - CYPRESS_KIBANA_URL: Url.format({ - protocol: config.get('servers.kibana.protocol'), - hostname: config.get('servers.kibana.hostname'), - port: config.get('servers.kibana.port'), - }), ...process.env, }, wait: true, @@ -65,22 +53,10 @@ export async function SecuritySolutionCypressCliFirefoxTestRunner({ cwd: resolve(__dirname, '../../plugins/security_solution'), env: { FORCE_COLOR: '1', - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_baseUrl: Url.format(config.get('servers.kibana')), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_protocol: config.get('servers.kibana.protocol'), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_hostname: config.get('servers.kibana.hostname'), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_configport: config.get('servers.kibana.port'), + CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - CYPRESS_KIBANA_URL: Url.format({ - protocol: config.get('servers.kibana.protocol'), - hostname: config.get('servers.kibana.hostname'), - port: config.get('servers.kibana.port'), - }), ...process.env, }, wait: true, @@ -126,22 +102,10 @@ export async function SecuritySolutionCypressVisualTestRunner({ getService }: Ft cwd: resolve(__dirname, '../../plugins/security_solution'), env: { FORCE_COLOR: '1', - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_baseUrl: Url.format(config.get('servers.kibana')), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_protocol: config.get('servers.kibana.protocol'), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_hostname: config.get('servers.kibana.hostname'), - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_configport: config.get('servers.kibana.port'), + CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - CYPRESS_KIBANA_URL: Url.format({ - protocol: config.get('servers.kibana.protocol'), - hostname: config.get('servers.kibana.hostname'), - port: config.get('servers.kibana.port'), - }), ...process.env, }, wait: true, @@ -153,6 +117,7 @@ export async function SecuritySolutionCypressUpgradeCliTestRunner({ getService, }: FtrProviderContext) { const log = getService('log'); + const config = getService('config'); await withProcRunner(log, async (procs) => { await procs.run('cypress', { @@ -161,18 +126,10 @@ export async function SecuritySolutionCypressUpgradeCliTestRunner({ cwd: resolve(__dirname, '../../plugins/security_solution'), env: { FORCE_COLOR: '1', - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_baseUrl: process.env.TEST_KIBANA_URL, - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_protocol: process.env.TEST_KIBANA_PROTOCOL, - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_hostname: process.env.TEST_KIBANA_HOSTNAME, - // eslint-disable-next-line @typescript-eslint/naming-convention - CYPRESS_configport: process.env.TEST_KIBANA_PORT, - CYPRESS_ELASTICSEARCH_URL: process.env.TEST_ES_URL, - CYPRESS_ELASTICSEARCH_USERNAME: process.env.TEST_ES_USER, - CYPRESS_ELASTICSEARCH_PASSWORD: process.env.TEST_ES_PASS, - CYPRESS_KIBANA_URL: process.env.TEST_KIBANA_URL, + CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), + CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), + CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), + CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), ...process.env, }, wait: true, diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index 00bc59eec7b6b..3a49278bd21a8 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -11,12 +11,16 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { deleteMetadataStream, deleteAllDocsFromMetadataCurrentIndex, + deleteAllDocsFromMetadataUnitedIndex, } from '../../../security_solution_endpoint_api_int/apis/data_stream_helper'; +import { IndexedHostsAndAlertsResponse } from '../../../../plugins/security_solution/common/endpoint/index_data'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'endpoint', 'header', 'endpointPageUtils']); - const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const endpointTestResources = getService('endpointTestResources'); + const policyTestResources = getService('policyTestResources'); const expectedData = [ [ @@ -30,81 +34,72 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'Last active', 'Actions', ], + ['Host-9fafsc3tqe', 'x', 'x', 'Warning', 'Windows', '10.231.117.28', '7.17.12', 'x', ''], [ - 'rezzani-7.example.com', - 'Unhealthy', - 'Default', - 'Failure', - 'windows 10.0', - '10.101.149.26, 2606:a000:ffc0:39:11ef:37b9:3371:578c', - '6.8.0', - 'Jul 21, 2021 @ 20:04:01.950', + 'Host-ku5jy6j0pw', + 'x', + 'x', + 'Warning', + 'Windows', + '10.246.87.11, 10.145.117.106,10.109.242.136', + '7.0.13', + 'x', '', ], [ - 'cadmann-4.example.com', - 'Unhealthy', - 'Default', + 'Host-o07wj6uaa5', + 'x', + 'x', 'Failure', - 'windows 10.0', - '10.192.213.130, 10.70.28.129', - '6.6.1', - 'Jul 21, 2021 @ 20:04:01.950', - '', - ], - [ - 'thurlow-9.example.com', - 'Unhealthy', - 'Default', - 'Success', - 'windows 10.0', - '10.46.229.234', - '6.0.0', - 'Jul 21, 2021 @ 20:04:01.950', + 'Windows', + '10.82.134.220, 10.47.25.170', + '7.11.13', + 'x', '', ], ]; + const formattedTableData = async () => { + const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); + + // Do not compare timestamps, Agent status, or Policy names since the data can be inconsistent. + for (let i = 1; i < tableData.length; i++) { + tableData[i][1] = 'x'; + tableData[i][2] = 'x'; + tableData[i][7] = 'x'; + } + + return tableData; + }; + describe('endpoint list', function () { const sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)); - + let indexedData: IndexedHostsAndAlertsResponse; describe('when initially navigating to page', () => { before(async () => { await deleteMetadataStream(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); + await deleteAllDocsFromMetadataUnitedIndex(getService); await pageObjects.endpoint.navigateToEndpointList(); }); - after(async () => { - await deleteMetadataStream(getService); - await deleteAllDocsFromMetadataCurrentIndex(getService); - }); it('finds no data in list and prompts onboarding to add policy', async () => { await testSubjects.exists('emptyPolicyTable'); }); - - it('finds data after load and polling', async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/endpoint/metadata/destination_index', - { useCreate: true } - ); - await pageObjects.endpoint.waitForTableToHaveData('endpointListTable', 1100); - const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); - expect(tableData).to.eql(expectedData); - }); }); describe('when there is data,', () => { before(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/endpoint/metadata/destination_index', - { useCreate: true } - ); + const endpointPackage = await policyTestResources.getEndpointPackage(); + await endpointTestResources.setMetadataTransformFrequency('1s', endpointPackage.version); + indexedData = await endpointTestResources.loadEndpointData({ numHosts: 3 }); await pageObjects.endpoint.navigateToEndpointList(); + await pageObjects.endpoint.waitForTableToHaveNumberOfEntries('endpointListTable', 3, 90000); }); after(async () => { - await deleteMetadataStream(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); + await deleteAllDocsFromMetadataUnitedIndex(getService); + await endpointTestResources.unloadEndpointData(indexedData); }); it('finds page title', async () => { @@ -113,8 +108,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('displays table data', async () => { - const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); - expect(tableData).to.eql(expectedData); + const tableData = await formattedTableData(); + expect(tableData.sort()).to.eql(expectedData.sort()); }); it('does not show the details flyout initially', async () => { @@ -161,144 +156,75 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ); expect(endpointDetailTitleNew).to.equal(endpointDetailTitleInitial); }); - }); - // This set of tests fails the flyout does not open in the before() and will be fixed in soon - describe.skip("has a url with an endpoint host's id", () => { - before(async () => { - await pageObjects.endpoint.navigateToEndpointList( - 'selected_endpoint=3838df35-a095-4af4-8fce-0b6d78793f2e' - ); - }); + it('for the kql query: na, table shows an empty list', async () => { + await pageObjects.endpoint.navigateToEndpointList(); + await browser.refresh(); + const adminSearchBar = await testSubjects.find('adminSearchBar'); + await adminSearchBar.clearValueWithKeyboard(); + await adminSearchBar.type('na'); + const querySubmitButton = await testSubjects.find('querySubmitButton'); + await querySubmitButton.click(); + const expectedDataFromQuery = [ + [ + 'Endpoint', + 'Agent status', + 'Policy', + 'Policy status', + 'OS', + 'IP address', + 'Version', + 'Last active', + 'Actions', + ], + ['No items found'], + ]; - it('shows a flyout', async () => { - await testSubjects.existOrFail('endpointDetailsFlyoutBody'); - await testSubjects.existOrFail('endpointDetailsUpperList'); - await testSubjects.existOrFail('endpointDetailsLowerList'); + await pageObjects.endpoint.waitForTableToNotHaveData('endpointListTable'); + const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); + expect(tableData).to.eql(expectedDataFromQuery); }); - it('displays details row headers', async () => { - const expectedHeaders = [ - 'OS', - 'Last Seen', - 'Alerts', - 'Integration Policy', - 'Policy Status', - 'IP Address', - 'Hostname', - 'Version', + it('for the kql filtering for united.endpoint.host.hostname : "Host-ku5jy6j0pw", table shows 1 item', async () => { + const adminSearchBar = await testSubjects.find('adminSearchBar'); + await adminSearchBar.clearValueWithKeyboard(); + await adminSearchBar.type( + 'united.endpoint.host.hostname : "Host-ku5jy6j0pw" or host.hostname : "Host-ku5jy6j0pw" ' + ); + const querySubmitButton = await testSubjects.find('querySubmitButton'); + await querySubmitButton.click(); + const expectedDataFromQuery = [ + [ + 'Endpoint', + 'Agent status', + 'Policy', + 'Policy status', + 'OS', + 'IP address', + 'Version', + 'Last active', + 'Actions', + ], + [ + 'Host-ku5jy6j0pw', + 'x', + 'x', + 'Warning', + 'Windows', + '10.246.87.11, 10.145.117.106,10.109.242.136', + '7.0.13', + 'x', + '', + ], ]; - const keys = await pageObjects.endpoint.endpointFlyoutDescriptionKeys( - 'endpointDetailsFlyout' + await pageObjects.endpoint.waitForTableToHaveNumberOfEntries( + 'endpointListTable', + 1, + 90000 ); - expect(keys).to.eql(expectedHeaders); + const tableData = await formattedTableData(); + expect(tableData.sort()).to.eql(expectedDataFromQuery.sort()); }); - - it('displays details row descriptions', async () => { - const values = await pageObjects.endpoint.endpointFlyoutDescriptionValues( - 'endpointDetailsFlyout' - ); - - expect(values).to.eql([ - 'Windows 10', - '', - '0', - 'Default', - 'Unknown', - '10.101.149.262606:a000:ffc0:39:11ef:37b9:3371:578c', - 'rezzani-7.example.com', - '6.8.0', - ]); - }); - }); - }); - - describe('displays the correct table data for the kql queries', () => { - before(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/endpoint/metadata/destination_index', - { useCreate: true } - ); - await pageObjects.endpoint.navigateToEndpointList(); - }); - after(async () => { - await deleteMetadataStream(getService); - await deleteAllDocsFromMetadataCurrentIndex(getService); - }); - it('for the kql query: na, table shows an empty list', async () => { - const adminSearchBar = await testSubjects.find('adminSearchBar'); - await adminSearchBar.clearValueWithKeyboard(); - await adminSearchBar.type('na'); - const querySubmitButton = await testSubjects.find('querySubmitButton'); - await querySubmitButton.click(); - const expectedDataFromQuery = [ - [ - 'Endpoint', - 'Agent status', - 'Policy', - 'Policy status', - 'OS', - 'IP address', - 'Version', - 'Last active', - 'Actions', - ], - ['No items found'], - ]; - - await pageObjects.endpoint.waitForTableToNotHaveData('endpointListTable'); - const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); - expect(tableData).to.eql(expectedDataFromQuery); - }); - it('for the kql filtering for policy.applied.id : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A", table shows 2 items', async () => { - const adminSearchBar = await testSubjects.find('adminSearchBar'); - await adminSearchBar.clearValueWithKeyboard(); - await adminSearchBar.type( - // schema depends on applied package - 'Endpoint.policy.applied.id : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" ' + - 'or ' + - 'HostDetails.Endpoint.policy.applied.id : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" ' - ); - const querySubmitButton = await testSubjects.find('querySubmitButton'); - await querySubmitButton.click(); - const expectedDataFromQuery = [ - [ - 'Endpoint', - 'Agent status', - 'Policy', - 'Policy status', - 'OS', - 'IP address', - 'Version', - 'Last active', - 'Actions', - ], - [ - 'cadmann-4.example.com', - 'Unhealthy', - 'Default', - 'Failure', - 'windows 10.0', - '10.192.213.130, 10.70.28.129', - '6.6.1', - 'Jul 21, 2021 @ 20:04:01.950', - '', - ], - [ - 'thurlow-9.example.com', - 'Unhealthy', - 'Default', - 'Success', - 'windows 10.0', - '10.46.229.234', - '6.0.0', - 'Jul 21, 2021 @ 20:04:01.950', - '', - ], - ]; - await pageObjects.endpoint.waitForTableToHaveData('endpointListTable'); - const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); - expect(tableData).to.eql(expectedDataFromQuery); }); }); }); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts index b2421bd955f2d..45e6b410baee5 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts @@ -6,13 +6,29 @@ */ import { FtrProviderContext } from '../../ftr_provider_context'; +import { + deleteMetadataStream, + deleteAllDocsFromMetadataCurrentIndex, +} from '../../../security_solution_endpoint_api_int/apis/data_stream_helper'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { fleetIntegrations, trustedApps } = getPageObjects(['trustedApps', 'fleetIntegrations']); const policyTestResources = getService('policyTestResources'); const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); describe('When in the Fleet application', function () { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/endpoint/metadata/api_feature', { + useCreate: true, + }); + await browser.refresh(); + }); + after(async () => { + await deleteMetadataStream(getService); + await deleteAllDocsFromMetadataCurrentIndex(getService); + }); describe('and on the Endpoint Integration details page', () => { beforeEach(async () => { await fleetIntegrations.navigateToIntegrationDetails( diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 95299d8a81f5c..6d78c69798e94 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -8,8 +8,10 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; import { PolicyTestResourceInfo } from '../../services/endpoint_policy'; +import { IndexedHostsAndAlertsResponse } from '../../../../plugins/security_solution/common/endpoint/index_data'; export default function ({ getPageObjects, getService }: FtrProviderContext) { + const browser = getService('browser'); const pageObjects = getPageObjects([ 'common', 'endpoint', @@ -20,9 +22,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ]); const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); + const endpointTestResources = getService('endpointTestResources'); // FLAKY https://github.com/elastic/kibana/issues/100296 describe.skip('When on the Endpoint Policy Details Page', function () { + let indexedData: IndexedHostsAndAlertsResponse; + before(async () => { + const endpointPackage = await policyTestResources.getEndpointPackage(); + await endpointTestResources.setMetadataTransformFrequency('1s', endpointPackage.version); + indexedData = await endpointTestResources.loadEndpointData(); + await browser.refresh(); + }); + after(async () => { + await endpointTestResources.unloadEndpointData(indexedData); + }); describe('with an invalid policy id', () => { it('should display an error', async () => { await pageObjects.policy.navigateToPolicyDetails('invalid-id'); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/trusted_apps_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/trusted_apps_list.ts index 684df902bb499..52fb9b8fc8599 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/trusted_apps_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/trusted_apps_list.ts @@ -7,15 +7,27 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { IndexedHostsAndAlertsResponse } from '../../../../plugins/security_solution/common/endpoint/index_data'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'trustedApps']); const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const endpointTestResources = getService('endpointTestResources'); + const policyTestResources = getService('policyTestResources'); describe('When on the Trusted Apps list', function () { + let indexedData: IndexedHostsAndAlertsResponse; before(async () => { + const endpointPackage = await policyTestResources.getEndpointPackage(); + await endpointTestResources.setMetadataTransformFrequency('1s', endpointPackage.version); + indexedData = await endpointTestResources.loadEndpointData(); + await browser.refresh(); await pageObjects.trustedApps.navigateToTrustedAppsList(); }); + after(async () => { + await endpointTestResources.unloadEndpointData(indexedData); + }); it('should show page title', async () => { expect(await testSubjects.getVisibleText('header-page-title')).to.equal( diff --git a/x-pack/test/security_solution_endpoint/page_objects/endpoint_page.ts b/x-pack/test/security_solution_endpoint/page_objects/endpoint_page.ts index 9125e932cdd69..f0c0de05b5460 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/endpoint_page.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/endpoint_page.ts @@ -35,6 +35,20 @@ export function EndpointPageProvider({ getService, getPageObjects }: FtrProvider }); }, + async waitForTableToHaveNumberOfEntries( + dataTestSubj: string, + numberOfEntries = 1, + timeout = 2000 + ) { + await retry.waitForWithTimeout('table to have data', timeout, async () => { + const tableData = await pageObjects.endpointPageUtils.tableData(dataTestSubj); + if (tableData[1][0] === 'No items found' || tableData.length < numberOfEntries + 1) { + return false; + } + return true; + }); + }, + async waitForTableToNotHaveData(dataTestSubj: string) { await retry.waitForWithTimeout('table to not have data', 2000, async () => { const tableData = await pageObjects.endpointPageUtils.tableData(dataTestSubj); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/data_stream_helper.ts b/x-pack/test/security_solution_endpoint_api_int/apis/data_stream_helper.ts index 74a3b3c0b08f0..f848d4bf418e9 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/data_stream_helper.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/data_stream_helper.ts @@ -13,6 +13,7 @@ import { policyIndexPattern, metadataCurrentIndexPattern, telemetryIndexPattern, + METADATA_UNITED_INDEX, } from '../../../plugins/security_solution/common/endpoint/constants'; export function deleteDataStream(getService: (serviceName: 'es') => Client, index: string) { @@ -69,6 +70,12 @@ export async function deleteAllDocsFromMetadataCurrentIndex( await deleteAllDocsFromIndex(getService, metadataCurrentIndexPattern); } +export async function deleteAllDocsFromMetadataUnitedIndex( + getService: (serviceName: 'es') => Client +) { + await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); +} + export async function deleteEventsStream(getService: (serviceName: 'es') => Client) { await deleteDataStream(getService, eventsIndexPattern); } diff --git a/x-pack/test/timeline/common/config.ts b/x-pack/test/timeline/common/config.ts index ba1c8528527e4..fa8ddb2ad10a7 100644 --- a/x-pack/test/timeline/common/config.ts +++ b/x-pack/test/timeline/common/config.ts @@ -83,7 +83,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) // TO DO: Remove feature flags once we're good to go '--xpack.securitySolution.enableExperimental=["ruleRegistryEnabled"]', '--xpack.ruleRegistry.write.enabled=true', - `--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, + `--server.xsrf.allowlist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, ...(ssl ? [ `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, diff --git a/yarn.lock b/yarn.lock index 416d9c6b8ba5e..e7e369d4fbee2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2228,6 +2228,37 @@ enabled "2.0.x" kuler "^2.0.0" +"@dnd-kit/accessibility@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde" + integrity sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.1.1.tgz#c5ad6665931f5a51e74226220e58ac7514f3faf0" + integrity sha512-18YY5+1lTqJbGSg6JBSa/fjAOTUYAysFrQ5Ti8oppEPHFacQbC+owM51y2z2KN0LkDHBfGZKw2sFT7++ttwfpA== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^2.0.0" + tslib "^2.0.0" + +"@dnd-kit/sortable@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-4.0.0.tgz#81dd2b014a16527cf89602dc40060d9ee4dad352" + integrity sha512-teYVFy6mQG/u6F6CaGxAkzPfiNJvguFzWfJ/zonYQRxfANHX6QJ6GziMG9KON/Ae9Q2ODJP8vib+guWJrDXeGg== + dependencies: + "@dnd-kit/utilities" "^2.0.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-2.0.0.tgz#a8462dff65c6f606ecbe95273c7e263b14a1ab97" + integrity sha512-bjs49yMNzMM+BYRsBUhTqhTk6HEvhuY3leFt6Em6NaYGgygaMbtGbbXof/UXBv7rqyyi0OkmBBnrCCcxqS2t/g== + dependencies: + tslib "^2.0.0" + "@dsherret/to-absolute-glob@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@dsherret/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1f6475dc8bd974cea07a2daf3864b317b1dd332c" @@ -2404,15 +2435,6 @@ async-retry "^1.2.3" strip-ansi "^5.2.0" -"@elastic/good@^9.0.1-kibana3": - version "9.0.1-kibana3" - resolved "https://registry.yarnpkg.com/@elastic/good/-/good-9.0.1-kibana3.tgz#a70c2b30cbb4f44d1cf4a464562e0680322eac9b" - integrity sha512-UtPKr0TmlkL1abJfO7eEVUTqXWzLKjMkz+65FvxU/Ub9kMAr4No8wHLRfDHFzBkWoDIbDWygwld011WzUnea1Q== - dependencies: - "@hapi/hoek" "9.x.x" - "@hapi/oppsy" "3.x.x" - "@hapi/validate" "1.x.x" - "@elastic/makelogs@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@elastic/makelogs/-/makelogs-6.0.0.tgz#d6d74d5d0f020123c54160370d49ca5e0aab1fe1" @@ -2897,14 +2919,6 @@ resolved "https://registry.yarnpkg.com/@hapi/file/-/file-2.0.0.tgz#2ecda37d1ae9d3078a67c13b7da86e8c3237dfb9" integrity sha512-WSrlgpvEqgPWkI18kkGELEZfXr0bYLtr16iIN4Krh9sRnzBZN6nnWxHFxtsnP684wueEySBbXPDg/WfA9xJdBQ== -"@hapi/good-squeeze@6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@hapi/good-squeeze/-/good-squeeze-6.0.0.tgz#bb72d6869cd7398b615a6b7270f630dc4f76aebf" - integrity sha512-UgHAF9Lm8fJPzgf2HymtowOwNc1+IL+p08YTVR+XA4d8nmyE1t9x3RLA4riqldnOKHkVqGakJ1jGqUG7jk77Cg== - dependencies: - "@hapi/hoek" "9.x.x" - fast-safe-stringify "2.x.x" - "@hapi/h2o2@^9.1.0": version "9.1.0" resolved "https://registry.yarnpkg.com/@hapi/h2o2/-/h2o2-9.1.0.tgz#b223f4978b6f2b0d7d9db10a84a567606c4c3551" @@ -2992,13 +3006,6 @@ "@hapi/hoek" "^9.0.4" "@hapi/vise" "^4.0.0" -"@hapi/oppsy@3.x.x": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@hapi/oppsy/-/oppsy-3.0.0.tgz#1ae397e200e86d0aa41055f103238ed8652947ca" - integrity sha512-0kfUEAqIi21GzFVK2snMO07znMEBiXb+/pOx1dmgOO9TuvFstcfmHU5i56aDfiFP2DM5WzQCU2UWc2gK1lMDhQ== - dependencies: - "@hapi/hoek" "9.x.x" - "@hapi/pez@^5.0.1": version "5.0.3" resolved "https://registry.yarnpkg.com/@hapi/pez/-/pez-5.0.3.tgz#b75446e6fef8cbb16816573ab7da1b0522e7a2a1" @@ -3750,10 +3757,6 @@ version "0.0.0" uid "" -"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging": - version "0.0.0" - uid "" - "@kbn/logging@link:bazel-bin/packages/kbn-logging": version "0.0.0" uid "" @@ -14493,7 +14496,7 @@ fast-redact@^3.0.0: resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.0.0.tgz#ac2f9e36c9f4976f5db9fb18c6ffbaf308cf316d" integrity sha512-a/S/Hp6aoIjx7EmugtzLqXmcNsyFszqbt6qQ99BdG61QjBZF6shNis0BYR6TsZOQ1twYc0FN2Xdhwwbv6+KD0w== -fast-safe-stringify@2.x.x, fast-safe-stringify@^2.0.4, fast-safe-stringify@^2.0.7: +fast-safe-stringify@^2.0.4, fast-safe-stringify@^2.0.7: version "2.0.8" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.8.tgz#dc2af48c46cf712b683e849b2bbd446b32de936f" integrity sha512-lXatBjf3WPjmWD6DpIZxkeSsCOwqI0maYMpgDlx8g4U2qi4lbjA9oH/HD2a87G+KfsUmo5WbJFmqBZlPxtptag==