diff --git a/NOTICE.txt b/NOTICE.txt index 4ede43610ca7b..1694193892e16 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -295,7 +295,7 @@ MIT License http://www.opensource.org/licenses/mit-license --- This product includes code that is adapted from mapbox-gl-js, which is available under a "BSD-3-Clause" license. -https://github.com/mapbox/mapbox-gl-js/blob/master/src/util/image.js +https://github.com/mapbox/mapbox-gl-js/blob/v1.13.2/src/util/image.js Copyright (c) 2016, Mapbox diff --git a/dev_docs/api_welcome.mdx b/dev_docs/api_welcome.mdx index cca911cc6cdd0..cf88bf7eec0da 100644 --- a/dev_docs/api_welcome.mdx +++ b/dev_docs/api_welcome.mdx @@ -44,7 +44,7 @@ This documentation is being automatically generated using an There is one extra step required to have your API docs show up in the _navigation_ of the docs system. Follow the instructions to learn how to configure the navigation menu. The nav file you need to - edit is: [https://github.com/elastic/elastic-docs/blob/master/config/nav-kibana-dev.ts](https://github.com/elastic/elastic-docs/blob/master/config/nav-kibana-dev.ts) + edit is: [https://github.com/elastic/elastic-docs/blob/main/config/nav-kibana-dev.ts](https://github.com/elastic/elastic-docs/blob/main/config/nav-kibana-dev.ts) Your API docs will exist in the top level [`api_docs` folder](https://github.com/elastic/kibana/tree/main/api_docs) and will use a doc id of the pattern `kib${PluginName}PluginApi`. diff --git a/dev_docs/contributing/best_practices.mdx b/dev_docs/contributing/best_practices.mdx index d0ae34155d7eb..d7aa42946eac3 100644 --- a/dev_docs/contributing/best_practices.mdx +++ b/dev_docs/contributing/best_practices.mdx @@ -141,6 +141,9 @@ export type foo: string | AnInterface; Running Kibana with `yarn start --run-examples` will include all [example plugins](https://github.com/elastic/kibana/tree/main/examples). These are tested examples of platform services in use. We strongly encourage anyone providing a platform level service or to include a tutorial that links to a tested example plugin. This is better than relying on copied code snippets, which can quickly get out of date. +You can also visit these [examples plugins hosted online](https://demo.kibana.dev/8.0/app/home). Note that because anonymous access is enabled, some +of the demos are currently not working. + ## Performance Build with scalability in mind. @@ -150,6 +153,8 @@ Build with scalability in mind. - Consider large data sets, that span a long time range - Consider slow internet and low bandwidth environments + + ## Accessibility Did you know Kibana makes a public statement about our commitment to creating an accessible product for people @@ -202,13 +207,13 @@ Kibana code base, try not to contribute to this volatility. Doing this can: All of the above contributes to more bugs being found in the QA cycle and can cause a delay in the release. Prefer instead to merge your large change right _after_ feature freeze. If you are worried about missing your initial release version goals, review our -[release train philophy](https://github.com/elastic/dev/blob/master/shared/time-based-releases.md). It's okay! +. It's okay! ### Size -When possible, build features with incrementals sets of small and focused PRs, but don't check in unused code, and don't expose any feature on master that you would not be comfortable releasing. +When possible, build features with incremental sets of small and focused PRs, but don't check in unused code, and don't expose any feature on main that you would not be comfortable releasing. ![product_stages](../assets/product_stages.png) diff --git a/dev_docs/contributing/code_walkthrough.mdx b/dev_docs/contributing/code_walkthrough.mdx index 62965add07578..74995c246503c 100644 --- a/dev_docs/contributing/code_walkthrough.mdx +++ b/dev_docs/contributing/code_walkthrough.mdx @@ -21,7 +21,7 @@ Managed by the operations team to contain Jenkins settings. Can be ignored by fo ## [.github](https://github.com/elastic/kibana/tree/main/.github) -Contains GitHub configuration settings. This file contains issue templates, and the [CODEOWNERS](https://github.com/elastic/kibana/blob/main/.github/CODEOWNERS) file. It's important for teams to keep the CODEOWNERS file up-to-date so the right team is pinged for a code owner review on PRs that edit certain files. Note that the `CODEOWNERS` file only exists on the main/master branch, and is not backported to other branches in the repo. +Contains GitHub configuration settings. This file contains issue templates, and the [CODEOWNERS](https://github.com/elastic/kibana/blob/main/.github/CODEOWNERS) file. It's important for teams to keep the CODEOWNERS file up-to-date so the right team is pinged for a code owner review on PRs that edit certain files. Note that the `CODEOWNERS` file only exists on the main branch, and is not backported to other branches in the repo. ## [api_docs](https://github.com/elastic/kibana/tree/main/api_docs) diff --git a/dev_docs/contributing/how_we_use_github.mdx b/dev_docs/contributing/how_we_use_github.mdx index 38391874b87bf..247fca97335c7 100644 --- a/dev_docs/contributing/how_we_use_github.mdx +++ b/dev_docs/contributing/how_we_use_github.mdx @@ -15,16 +15,14 @@ We follow the [GitHub forking model](https://help.github.com/articles/fork-a-rep At Elastic, all products in the stack, including Kibana, are released at the same time with the same version number. Most of these projects have the following branching strategy: -- master is the next major version. -- `.x` is the next minor version. -- `.` is the next release of a minor version, including patch releases. +- `main` points to the next minor version. +- `.` is the previously released minor version, including patch releases. -As an example, let’s assume that the 7.x branch is currently a not-yet-released 7.6.0. Once 7.6.0 has reached feature freeze, it will be branched to 7.6 and 7.x will be updated to reflect 7.7.0. The release of 7.6.0 and subsequent patch releases will be cut from the 7.6 branch. At any time, you can verify the current version of a branch by inspecting the version attribute in the package.json file within the Kibana source. +As an example, let’s assume that the main branch is currently a not-yet-released 8.1.0. Once 8.1.0 has reached feature freeze, it will be branched to 8.1 and main will be updated to reflect 8.2.0. The release of 8.1.0 and subsequent patch releases will be cut from the 8.1 branch. At any time, you can verify the current version of a branch by inspecting the version attribute in the package.json file within the Kibana source. -Pull requests are made into the master branch and then backported when it is safe and appropriate. +Pull requests are made into the main branch and only backported when it is safe and appropriate. -- Breaking changes do not get backported and only go into master. -- All non-breaking changes can be backported to the `.x` branch. +- Breaking changes can _only_ be made to `main` if there has been at least an 18 month deprecation period _and_ the breaking change has been approved. Telemetry showing current usage is crucial for gaining approval. - Features should not be backported to a `.` branch. - Bug fixes can be backported to a `.` branch if the changes are safe and appropriate. Safety is a judgment call you make based on factors like the bug’s severity, test coverage, confidence in the changes, etc. Your reasoning should be included in the pull request description. - Documentation changes can be backported to any branch at any time. @@ -63,26 +61,26 @@ In order to assist with developer tooling we ask that all Elastic engineers use Rebasing can be tricky, and fixing merge conflicts can be even trickier because it involves force pushing. This is all compounded by the fact that attempting to push a rebased branch remotely will be rejected by git, and you’ll be prompted to do a pull, which is not at all what you should do (this will really mess up your branch’s history). -Here’s how you should rebase master onto your branch, and how to fix merge conflicts when they arise. +Here’s how you should rebase main onto your branch, and how to fix merge conflicts when they arise. -First, make sure master is up-to-date. +First, make sure main is up-to-date. ```bash -git checkout master +git checkout main git fetch upstream -git rebase upstream/master +git rebase upstream/main ``` -Then, check out your branch and rebase master on top of it, which will apply all of the new commits on master to your branch, and then apply all of your branch’s new commits after that. +Then, check out your branch and rebase main on top of it, which will apply all of the new commits on main to your branch, and then apply all of your branch’s new commits after that. ```bash git checkout name-of-your-branch -git rebase master +git rebase main ``` You want to make sure there are no merge conflicts. If there are merge conflicts, git will pause the rebase and allow you to fix the conflicts before continuing. -You can use git status to see which files contain conflicts. They’ll be the ones that aren’t staged for commit. Open those files, and look for where git has marked the conflicts. Resolve the conflicts so that the changes you want to make to the code have been incorporated in a way that doesn’t destroy work that’s been done in master. Refer to master’s commit history on GitHub if you need to gain a better understanding of how code is conflicting and how best to resolve it. +You can use git status to see which files contain conflicts. They’ll be the ones that aren’t staged for commit. Open those files, and look for where git has marked the conflicts. Resolve the conflicts so that the changes you want to make to the code have been incorporated in a way that doesn’t destroy work that’s been done in main. Refer to main commit history on GitHub if you need to gain a better understanding of how code is conflicting and how best to resolve it. Once you’ve resolved all of the merge conflicts, use git add -A to stage them to be committed, and then use git rebase --continue to tell git to continue the rebase. diff --git a/dev_docs/tutorials/expressions.mdx b/dev_docs/tutorials/expressions.mdx index c4b37a125838e..d9abf3dd57eb8 100644 --- a/dev_docs/tutorials/expressions.mdx +++ b/dev_docs/tutorials/expressions.mdx @@ -57,7 +57,7 @@ const result = await executionContract.getData(); ``` - Check the full spec of execute function [here](https://github.com/elastic/kibana/blob/main/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.execution.md) + Check the full spec of execute function In addition, on the browser side, there are two additional ways to run expressions and render the results. @@ -71,7 +71,7 @@ This is the easiest way to get expressions rendered inside your application. ``` - Check the full spec of ReactExpressionRenderer component props [here](https://github.com/elastic/kibana/blob/main/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.md) + Check the full spec of ReactExpressionRenderer component props #### Expression loader @@ -83,7 +83,7 @@ const handler = loader(domElement, expression, params); ``` - Check the full spec of expression loader params [here](https://github.com/elastic/kibana/blob/main/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md) + Check the full spec of expression loader params ### Creating new expression functions @@ -106,7 +106,7 @@ expressions.registerFunction(functionDefinition); ``` - Check the full interface of ExpressionFuntionDefinition [here](https://github.com/elastic/kibana/blob/main/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionfunctiondefinition.md) + Check the full interface of ExpressionFuntionDefinition ### Creating new expression renderers @@ -128,5 +128,5 @@ expressions.registerRenderer(rendererDefinition); ``` - Check the full interface of ExpressionRendererDefinition [here](https://github.com/elastic/kibana/blob/main/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderdefinition.md) + Check the full interface of ExpressionRendererDefinition diff --git a/dev_docs/tutorials/submit_a_pull_request.mdx b/dev_docs/tutorials/submit_a_pull_request.mdx index 5436ebf24e03e..f7d530f6cec66 100644 --- a/dev_docs/tutorials/submit_a_pull_request.mdx +++ b/dev_docs/tutorials/submit_a_pull_request.mdx @@ -23,7 +23,7 @@ After cloning your fork and navigating to the directory containing your fork: ```bash # Make sure you currently have the branch checked out off of which you'd like to work -git checkout master +git checkout main # Create a new branch git checkout -b fix-typos-in-readme @@ -76,7 +76,7 @@ See [Pull request review guidelines](https://www.elastic.co/guide/en/kibana/mast ## Updating your PR with upstream -If your pull request hasn't been updated with the latest code from the upstream/target branch, e.g. `master`, in the last 48 hours, it won't be able to merge until it is updated. This is to help prevent problems that could occur by merging stale code into upstream, e.g. something new was recently merged that is incompatible with something in your pull request. +If your pull request hasn't been updated with the latest code from the upstream/target branch, e.g. `main`, in the last 48 hours, it won't be able to merge until it is updated. This is to help prevent problems that could occur by merging stale code into upstream, e.g. something new was recently merged that is incompatible with something in your pull request. As an alternative to using `git` to manually update your branch, you can leave a comment on your pull request with the text `@elasticmachine merge upstream`. This will automatically update your branch and kick off CI for it. diff --git a/docs/developer/getting-started/debugging.asciidoc b/docs/developer/getting-started/debugging.asciidoc index f3308a1267386..1254462d2e4ea 100644 --- a/docs/developer/getting-started/debugging.asciidoc +++ b/docs/developer/getting-started/debugging.asciidoc @@ -130,71 +130,3 @@ Once you're finished, you can stop Kibana normally, then stop the {es} and APM s ---- ./scripts/compose.py stop ---- - -=== Using {kib} server logs -{kib} Logs is a great way to see what's going on in your application and to debug performance issues. Navigating through a large number of generated logs can be overwhelming, and following are some techniques that you can use to optimize the process. - -Start by defining a problem area that you are interested in. For example, you might be interested in seeing how a particular {kib} Plugin is performing, so no need to gather logs for all of {kib}. Or you might want to focus on a particular feature, such as requests from the {kib} server to the {es} server. -Depending on your needs, you can configure {kib} to generate logs for a specific feature. -[source,yml] ----- -logging: - appenders: - file: - type: file - fileName: ./kibana.log - layout: - type: json - -### gather all the Kibana logs into a file -logging.root: - appenders: [file] - level: all - -### or gather a subset of the logs -logging.loggers: - ### responses to an HTTP request - - name: http.server.response - level: debug - appenders: [file] - ### result of a query to the Elasticsearch server - - name: elasticsearch.query - level: debug - appenders: [file] - ### logs generated by my plugin - - name: plugins.myPlugin - level: debug - appenders: [file] ----- -WARNING: Kibana's `file` appender is configured to produce logs in https://www.elastic.co/guide/en/ecs/master/ecs-reference.html[ECS JSON] format. It's the only format that includes the meta information necessary for https://www.elastic.co/guide/en/apm/agent/nodejs/current/log-correlation.html[log correlation] out-of-the-box. - -The next step is to define what https://www.elastic.co/observability[observability tools] are available. -For a better experience, set up an https://www.elastic.co/guide/en/apm/get-started/current/observability-integrations.html[Observability integration] provided by Elastic to debug your application with the <> -To debug something quickly without setting up additional tooling, you can work with <> - -[[debugging-logs-apm-ui]] -==== APM UI -*Prerequisites* {kib} logs are configured to be in https://www.elastic.co/guide/en/ecs/master/ecs-reference.html[ECS JSON] format to include tracing identifiers. - -To debug {kib} with the APM UI, you must set up the APM infrastructure. You can find instructions for the setup process -https://www.elastic.co/guide/en/apm/get-started/current/observability-integrations.html[on the Observability integrations page]. - -Once you set up the APM infrastructure, you can enable the APM agent and put {kib} under load to collect APM events. To analyze the collected metrics and logs, use the APM UI as demonstrated https://www.elastic.co/guide/en/kibana/master/transactions.html#transaction-trace-sample[in the docs]. - -[[plain-kibana-logs]] -==== Plain {kib} logs -*Prerequisites* {kib} logs are configured to be in https://www.elastic.co/guide/en/ecs/master/ecs-reference.html[ECS JSON] format to include tracing identifiers. - -Open {kib} Logs and search for an operation you are interested in. -For example, suppose you want to investigate the response times for queries to the `/api/telemetry/v2/clusters/_stats` {kib} endpoint. -Open Kibana Logs and search for the HTTP server response for the endpoint. It looks similar to the following (some fields are omitted for brevity). -[source,json] ----- -{ - "message":"POST /api/telemetry/v2/clusters/_stats 200 1014ms - 43.2KB", - "log":{"level":"DEBUG","logger":"http.server.response"}, - "trace":{"id":"9b99131a6f66587971ef085ef97dfd07"}, - "transaction":{"id":"d0c5bbf14f5febca"} -} ----- -You are interested in the https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html#field-trace-id[trace.id] field, which is a unique identifier of a trace. The `trace.id` provides a way to group multiple events, like transactions, which belong together. You can search for `"trace":{"id":"9b99131a6f66587971ef085ef97dfd07"}` to get all the logs that belong to the same trace. This enables you to see how many {es} requests were triggered during the `9b99131a6f66587971ef085ef97dfd07` trace, what they looked like, what {es} endpoints were hit, and so on. 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 a9828f04672e9..b60f9ad17e9c4 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 elasticStackGetStarted: string; readonly upgrade: { readonly upgradingElasticStack: 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 cloud: { readonly indexManagement: 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 appSearch: { readonly apiRef: string; readonly apiClients: string; readonly apiKeys: string; readonly authentication: string; readonly crawlRules: string; readonly curations: string; readonly duplicateDocuments: string; readonly entryPoints: string; readonly guide: string; readonly indexingDocuments: string; readonly indexingDocumentsSchema: string; readonly logSettings: string; readonly metaEngines: string; readonly recisionTuning: string; readonly relevanceTuning: string; readonly resultSettings: string; readonly searchUI: string; readonly security: string; readonly synonyms: string; readonly webCrawler: string; readonly webCrawlerEventLogs: string; }; readonly enterpriseSearch: { readonly configuration: string; readonly licenseManagement: string; readonly mailService: string; readonly usersAccess: string; }; readonly workplaceSearch: { readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; readonly confluenceServer: string; readonly customSources: string; readonly customSourcePermissions: string; readonly documentPermissions: string; readonly dropbox: string; readonly externalIdentities: string; readonly gitHub: string; readonly gettingStarted: string; readonly gmail: string; readonly googleDrive: string; readonly indexingSchedule: string; readonly jiraCloud: string; readonly jiraServer: string; readonly oneDrive: string; readonly permissions: string; readonly salesforce: string; readonly security: string; readonly serviceNow: string; readonly sharePoint: string; readonly slack: string; readonly synch: string; readonly zendesk: 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 terms\_doc\_count\_error: 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: { readonly overview: string; readonly batchReindex: string; readonly remoteReindex: 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 troubleshootGaps: string; }; readonly securitySolution: { readonly trustedApps: string; }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; readonly percolate: string; readonly queryDsl: string; }; readonly date: { readonly dateMath: string; readonly dateMathIndexNames: string; }; readonly management: Record<string, string>; readonly ml: Record<string, string>; readonly transforms: Record<string, string>; readonly visualize: Record<string, string>; readonly apis: Readonly<{ bulkIndexAlias: string; byteSizeUnits: string; createAutoFollowPattern: string; createFollower: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; createRoleMappingTemplates: string; createRollupJobsRequest: string; createApiKey: string; createPipeline: string; createTransformRequest: string; cronExpressions: string; executeWatchActionModes: string; indexExists: string; openIndex: string; putComponentTemplate: string; painlessExecute: string; painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; timeUnits: string; updateTransform: string; }>; readonly observability: Readonly<{ guide: string; infrastructureThreshold: string; logsThreshold: string; metricsThreshold: string; monitorStatus: string; monitorUptime: string; tlsCertificate: string; uptimeDurationAnomaly: string; }>; readonly alerting: Record<string, string>; readonly maps: Readonly<{ guide: string; importGeospatialPrivileges: string; gdalTutorial: string; }>; readonly monitoring: Record<string, string>; readonly security: Readonly<{ apiKeyServiceSettings: string; clusterPrivileges: string; elasticsearchSettings: string; elasticsearchEnableSecurity: string; elasticsearchEnableApiKeys: string; indicesPrivileges: string; kibanaTLS: string; kibanaPrivileges: string; mappingRoles: string; mappingRolesFieldRules: string; runAsPrivilege: string; }>; readonly spaces: Readonly<{ kibanaLegacyUrlAliases: string; kibanaDisableLegacyUrlAliasesApi: 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<{ beatsAgentComparison: string; guide: string; fleetServer: string; fleetServerAddFleetServer: string; settings: string; settingsFleetServerHostSettings: string; settingsFleetServerProxySettings: string; troubleshooting: string; elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; installElasticAgent: string; installElasticAgentStandalone: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; apiKeysLearnMore: string; onPremRegistry: 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; }; readonly endpoints: { readonly troubleshooting: string; }; } | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | { readonly settings: string; readonly elasticStackGetStarted: string; readonly upgrade: { readonly upgradingElasticStack: 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 cloud: { readonly indexManagement: 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 appSearch: { readonly apiRef: string; readonly apiClients: string; readonly apiKeys: string; readonly authentication: string; readonly crawlRules: string; readonly curations: string; readonly duplicateDocuments: string; readonly entryPoints: string; readonly guide: string; readonly indexingDocuments: string; readonly indexingDocumentsSchema: string; readonly logSettings: string; readonly metaEngines: string; readonly precisionTuning: string; readonly relevanceTuning: string; readonly resultSettings: string; readonly searchUI: string; readonly security: string; readonly synonyms: string; readonly webCrawler: string; readonly webCrawlerEventLogs: string; }; readonly enterpriseSearch: { readonly configuration: string; readonly licenseManagement: string; readonly mailService: string; readonly usersAccess: string; }; readonly workplaceSearch: { readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; readonly confluenceServer: string; readonly customSources: string; readonly customSourcePermissions: string; readonly documentPermissions: string; readonly dropbox: string; readonly externalIdentities: string; readonly gitHub: string; readonly gettingStarted: string; readonly gmail: string; readonly googleDrive: string; readonly indexingSchedule: string; readonly jiraCloud: string; readonly jiraServer: string; readonly oneDrive: string; readonly permissions: string; readonly salesforce: string; readonly security: string; readonly serviceNow: string; readonly sharePoint: string; readonly slack: string; readonly synch: string; readonly zendesk: 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 terms\_doc\_count\_error: 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: { readonly overview: string; readonly batchReindex: string; readonly remoteReindex: 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 troubleshootGaps: string; }; readonly securitySolution: { readonly trustedApps: string; }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; readonly percolate: string; readonly queryDsl: string; }; readonly date: { readonly dateMath: string; readonly dateMathIndexNames: string; }; readonly management: Record<string, string>; readonly ml: Record<string, string>; readonly transforms: Record<string, string>; readonly visualize: Record<string, string>; readonly apis: Readonly<{ bulkIndexAlias: string; byteSizeUnits: string; createAutoFollowPattern: string; createFollower: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; createRoleMappingTemplates: string; createRollupJobsRequest: string; createApiKey: string; createPipeline: string; createTransformRequest: string; cronExpressions: string; executeWatchActionModes: string; indexExists: string; openIndex: string; putComponentTemplate: string; painlessExecute: string; painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; timeUnits: string; updateTransform: string; }>; readonly observability: Readonly<{ guide: string; infrastructureThreshold: string; logsThreshold: string; metricsThreshold: string; monitorStatus: string; monitorUptime: string; tlsCertificate: string; uptimeDurationAnomaly: string; }>; readonly alerting: Record<string, string>; readonly maps: Readonly<{ guide: string; importGeospatialPrivileges: string; gdalTutorial: string; }>; readonly monitoring: Record<string, string>; readonly security: Readonly<{ apiKeyServiceSettings: string; clusterPrivileges: string; elasticsearchSettings: string; elasticsearchEnableSecurity: string; elasticsearchEnableApiKeys: string; indicesPrivileges: string; kibanaTLS: string; kibanaPrivileges: string; mappingRoles: string; mappingRolesFieldRules: string; runAsPrivilege: string; }>; readonly spaces: Readonly<{ kibanaLegacyUrlAliases: string; kibanaDisableLegacyUrlAliasesApi: string; }>; readonly watcher: Record<string, string>; readonly ccs: Record<string, string>; readonly plugins: { azureRepo: string; gcsRepo: string; hdfsRepo: string; s3Repo: string; snapshotRestoreRepos: string; mapperSize: string; }; readonly snapshotRestore: Record<string, string>; readonly ingest: Record<string, string>; readonly fleet: Readonly<{ beatsAgentComparison: string; guide: string; fleetServer: string; fleetServerAddFleetServer: string; settings: string; settingsFleetServerHostSettings: string; settingsFleetServerProxySettings: string; troubleshooting: string; elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; installElasticAgent: string; installElasticAgentStandalone: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; apiKeysLearnMore: string; onPremRegistry: 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; }; readonly endpoints: { readonly troubleshooting: string; }; } | | diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index 77a250a14f929..27ea7f4dc7cd0 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -101,8 +101,8 @@ Changing these settings may disable features of the APM App. | `xpack.apm.indices.sourcemap` {ess-icon} | Matcher for all source map indices. Defaults to `apm-*`. -| `xpack.apm.autocreateApmIndexPattern` {ess-icon} - | Set to `false` to disable the automatic creation of the APM index pattern when the APM app is opened. Defaults to `true`. +| `xpack.apm.autoCreateApmDataView` {ess-icon} + | Set to `false` to disable the automatic creation of the APM data view when the APM app is opened. Defaults to `true`. |=== -// end::general-apm-settings[] \ No newline at end of file +// end::general-apm-settings[] diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index f0dfeb619bb38..a088f31937cc8 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -87,6 +87,7 @@ Optional properties are: `data_output_id`:: ID of the output to send data (Need to be identical to `monitoring_output_id`) `monitoring_output_id`:: ID of the output to send monitoring data. (Need to be identical to `data_output_id`) `package_policies`:: List of integration policies to add to this policy. + `id`::: Unique ID of the integration policy. The ID may be a number or string. `name`::: (required) Name of the integration policy. `package`::: (required) Integration that this policy configures `name`:::: Name of the integration associated with this policy. @@ -128,6 +129,7 @@ xpack.fleet.agentPolicies: - package: name: system name: System Integration + id: preconfigured-system inputs: - type: system/metrics enabled: true diff --git a/docs/settings/task-manager-settings.asciidoc b/docs/settings/task-manager-settings.asciidoc index c61ef83953347..286bb71542b3a 100644 --- a/docs/settings/task-manager-settings.asciidoc +++ b/docs/settings/task-manager-settings.asciidoc @@ -9,51 +9,59 @@ Task Manager runs background tasks by polling for work on an interval. You can [float] [[task-manager-settings]] -==== Task Manager settings +==== Task Manager settings -[cols="2*<"] -|=== -| `xpack.task_manager.max_attempts` - | The maximum number of times a task will be attempted before being abandoned as failed. Defaults to 3. -| `xpack.task_manager.poll_interval` - | How often, in milliseconds, the task manager will look for more work. Defaults to 3000 and cannot be lower than 100. -| `xpack.task_manager.request_capacity` - | How many requests can Task Manager buffer before it rejects new requests. Defaults to 1000. +`xpack.task_manager.max_attempts`:: +The maximum number of times a task will be attempted before being abandoned as failed. Defaults to 3. - | `xpack.task_manager.max_workers` - | The maximum number of tasks that this Kibana instance will run simultaneously. Defaults to 10. - Starting in 8.0, it will not be possible to set the value greater than 100. +`xpack.task_manager.poll_interval`:: +How often, in milliseconds, the task manager will look for more work. Defaults to 3000 and cannot be lower than 100. - | `xpack.task_manager.` - `monitored_stats_health_verbose_log.enabled` - | This flag will enable automatic warn and error logging if task manager self detects a performance issue, such as the time between when a task is scheduled to execute and when it actually executes. Defaults to false. +`xpack.task_manager.request_capacity`:: +How many requests can Task Manager buffer before it rejects new requests. Defaults to 1000. - | `xpack.task_manager.` - `monitored_stats_health_verbose_log.` - `warn_delayed_task_start_in_seconds` - | The amount of seconds we allow a task to delay before printing a warning server log. Defaults to 60. +`xpack.task_manager.max_workers`:: +The maximum number of tasks that this Kibana instance will run simultaneously. Defaults to 10. +Starting in 8.0, it will not be possible to set the value greater than 100. - | `xpack.task_manager.ephemeral_tasks.enabled` - | Enables an experimental feature that executes a limited (and configurable) number of actions in the same task as the alert which triggered them. - These action tasks will reduce the latency of the time it takes an action to run after it's triggered, but are not persisted as SavedObjects. - These non-persisted action tasks have a risk that they won't be run at all if the Kibana instance running them exits unexpectedly. Defaults to false. +`xpack.task_manager.monitored_stats_health_verbose_log.enabled`:: +This flag will enable automatic warn and error logging if task manager self detects a performance issue, such as the time between when a task is scheduled to execute and when it actually executes. Defaults to false. + +`xpack.task_manager.monitored_stats_health_verbose_log.warn_delayed_task_start_in_seconds`:: +The amount of seconds we allow a task to delay before printing a warning server log. Defaults to 60. + +`xpack.task_manager.ephemeral_tasks.enabled`:: +Enables an experimental feature that executes a limited (and configurable) number of actions in the same task as the alert which triggered them. +These action tasks will reduce the latency of the time it takes an action to run after it's triggered, but are not persisted as SavedObjects. +These non-persisted action tasks have a risk that they won't be run at all if the Kibana instance running them exits unexpectedly. Defaults to false. + +`xpack.task_manager.ephemeral_tasks.request_capacity`:: +Sets the size of the ephemeral queue defined above. Defaults to 10. - | `xpack.task_manager.ephemeral_tasks.request_capacity` - | Sets the size of the ephemeral queue defined above. Defaults to 10. -|=== [float] [[task-manager-health-settings]] -==== Task Manager Health settings +==== Task Manager Health settings Settings that configure the <> endpoint. -[cols="2*<"] -|=== -| `xpack.task_manager.` -`monitored_task_execution_thresholds` - | Configures the threshold of failed task executions at which point the `warn` or `error` health status is set under each task type execution status (under `stats.runtime.value.execution.result_frequency_percent_as_number[${task type}].status`). This setting allows configuration of both the default level and a custom task type specific level. By default, this setting is configured to mark the health of every task type as `warning` when it exceeds 80% failed executions, and as `error` at 90%. Custom configurations allow you to reduce this threshold to catch failures sooner for task types that you might consider critical, such as alerting tasks. This value can be set to any number between 0 to 100, and a threshold is hit when the value *exceeds* this number. This means that you can avoid setting the status to `error` by setting the threshold at 100, or hit `error` the moment any task fails by setting the threshold to 0 (as it will exceed 0 once a single failure occurs). - -|=== +`xpack.task_manager.monitored_task_execution_thresholds`:: +Configures the threshold of failed task executions at which point the `warn` or +`error` health status is set under each task type execution status +(under `stats.runtime.value.execution.result_frequency_percent_as_number[${task type}].status`). ++ +This setting allows configuration of both the default level and a +custom task type specific level. By default, this setting is configured to mark +the health of every task type as `warning` when it exceeds 80% failed executions, +and as `error` at 90%. ++ +Custom configurations allow you to reduce this threshold to catch failures sooner +for task types that you might consider critical, such as alerting tasks. ++ +This value can be set to any number between 0 to 100, and a threshold is hit +when the value *exceeds* this number. This means that you can avoid setting the +status to `error` by setting the threshold at 100, or hit `error` the moment +any task fails by setting the threshold to 0 (as it will exceed 0 once a +single failure occurs). diff --git a/docs/settings/telemetry-settings.asciidoc b/docs/settings/telemetry-settings.asciidoc index 0329e2f010e80..65f78a2eaf12d 100644 --- a/docs/settings/telemetry-settings.asciidoc +++ b/docs/settings/telemetry-settings.asciidoc @@ -17,29 +17,26 @@ See our https://www.elastic.co/legal/privacy-statement[Privacy Statement] to lea [[telemetry-general-settings]] ==== General telemetry settings -[cols="2*<"] -|=== -|[[telemetry-enabled]] `telemetry.enabled` - | Set to `true` to send cluster statistics to Elastic. Reporting your + +[[telemetry-enabled]] `telemetry.enabled`:: + Set to `true` to send cluster statistics to Elastic. Reporting your cluster statistics helps us improve your user experience. Your data is never shared with anyone. Set to `false` to disable statistics reporting from any browser connected to the {kib} instance. Defaults to `true`. -| `telemetry.sendUsageFrom` - | Set to `'server'` to report the cluster statistics from the {kib} server. +`telemetry.sendUsageFrom`:: + Set to `'server'` to report the cluster statistics from the {kib} server. If the server fails to connect to our endpoint at https://telemetry.elastic.co/, it assumes it is behind a firewall and falls back to `'browser'` to send it from users' browsers when they are navigating through {kib}. Defaults to `'server'`. -|[[telemetry-optIn]] `telemetry.optIn` - | Set to `true` to automatically opt into reporting cluster statistics. You can also opt out through +[[telemetry-optIn]] `telemetry.optIn`:: + Set to `true` to automatically opt into reporting cluster statistics. You can also opt out through *Advanced Settings* in {kib}. Defaults to `true`. -| `telemetry.allowChangingOptInStatus` - | Set to `true` to allow overwriting the <> setting via the {kib} UI. Defaults to `true`. + - -|=== - +`telemetry.allowChangingOptInStatus`:: + Set to `true` to allow overwriting the <> setting via the {kib} UI. Defaults to `true`. + ++ [NOTE] ============ When `false`, <> must be `true`. To disable telemetry and not allow users to change that parameter, use <>. diff --git a/docs/setup/upgrade.asciidoc b/docs/setup/upgrade.asciidoc index a139b8a50ca4d..c828b837d8efd 100644 --- a/docs/setup/upgrade.asciidoc +++ b/docs/setup/upgrade.asciidoc @@ -44,13 +44,20 @@ a| [[upgrade-before-you-begin]] === Before you begin -WARNING: {kib} automatically runs upgrade migrations when required. To roll back to an earlier version in case of an upgrade failure, you **must** have a {ref}/snapshot-restore.html[backup snapshot] available. This snapshot must include the `kibana` feature state or all `kibana*` indices. For more information see <>. +[WARNING] +==== +{kib} automatically runs upgrade migrations when required. To roll back to an +earlier version in case of an upgrade failure, you **must** have a +{ref}/snapshot-restore.html[backup snapshot] that includes the `kibana` feature +state. Snapshots include this feature state by default. + +For more information, refer to <>. +==== Before you upgrade {kib}: * Consult the <>. -* {ref}/snapshots-take-snapshot.html[Take a snapshot] of your data. To roll back to an earlier version, the snapshot must include the `kibana` feature state or all `.kibana*` indices. -* Although not a requirement for rollbacks, we recommend taking a snapshot of all {kib} indices created by the plugins you use such as the `.reporting*` indices created by the reporting plugin. +* {ref}/snapshots-take-snapshot.html[Take a snapshot] of your data. To roll back to an earlier version, the snapshot must include the `kibana` feature state. * Before you upgrade production servers, test the upgrades in a dev environment. * See <> for common reasons upgrades fail and how to prevent these. * If you are using custom plugins, check that a compatible version is diff --git a/docs/setup/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index c47c2c1745e94..e9e1b757fd71d 100644 --- a/docs/setup/upgrade/upgrade-migrations.asciidoc +++ b/docs/setup/upgrade/upgrade-migrations.asciidoc @@ -151,17 +151,18 @@ In order to rollback after a failed upgrade migration, the saved object indices [float] ===== Rollback by restoring a backup snapshot: -1. Before proceeding, {ref}/snapshots-take-snapshot.html[take a snapshot] that contains the `kibana` feature state or all `.kibana*` indices. +1. Before proceeding, {ref}/snapshots-take-snapshot.html[take a snapshot] that contains the `kibana` feature state. + Snapshots include this feature state by default. 2. Shutdown all {kib} instances to be 100% sure that there are no instances currently performing a migration. 3. Delete all saved object indices with `DELETE /.kibana*` -4. {ref}/snapshots-restore-snapshot.html[Restore] the `kibana` feature state or all `.kibana* indices and their aliases from the snapshot. +4. {ref}/snapshots-restore-snapshot.html[Restore] the `kibana` feature state from the snapshot. 5. Start up all {kib} instances on the older version you wish to rollback to. [float] ===== (Not recommended) Rollback without a backup snapshot: 1. Shutdown all {kib} instances to be 100% sure that there are no {kib} instances currently performing a migration. -2. {ref}/snapshots-take-snapshot.html[Take a snapshot] that includes the `kibana` feature state or all `.kibana*` indices. +2. {ref}/snapshots-take-snapshot.html[Take a snapshot] that includes the `kibana` feature state. Snapshots include this feature state by default. 3. Delete the version specific indices created by the failed upgrade migration. E.g. if you wish to rollback from a failed upgrade to v7.12.0 `DELETE /.kibana_7.12.0_*,.kibana_task_manager_7.12.0_*` 4. Inspect the output of `GET /_cat/aliases`. If either the `.kibana` and/or `.kibana_task_manager` alias is missing, these will have to be created manually. Find the latest index from the output of `GET /_cat/indices` and create the missing alias to point to the latest index. E.g. if the `.kibana` alias was missing and the latest index is `.kibana_3` create a new alias with `POST /.kibana_3/_aliases/.kibana`. 5. Remove the write block from the rollback indices. `PUT /.kibana,.kibana_task_manager/_settings {"index.blocks.write": false}` diff --git a/docs/user/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index 74a32b94975ad..5f3c566e82d42 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -15,7 +15,7 @@ Rules and connectors log to the Kibana logger with tags of [alerting] and [actio [source, txt] -------------------------------------------------- -server log [11:39:40.389] [error][alerting][alerting][plugins][plugins] Executing Alert "5b6237b0-c6f6-11eb-b0ff-a1a0cbcf29b6" has resulted in Error: Saved object [action/fdbc8610-c6f5-11eb-b0ff-a1a0cbcf29b6] not found +server log [11:39:40.389] [error][alerting][alerting][plugins][plugins] Executing Rule "5b6237b0-c6f6-11eb-b0ff-a1a0cbcf29b6" has resulted in Error: Saved object [action/fdbc8610-c6f5-11eb-b0ff-a1a0cbcf29b6] not found -------------------------------------------------- Some of the resources, such as saved objects and API keys, may no longer be available or valid, yielding error messages about those missing resources. diff --git a/docs/user/alerting/troubleshooting/event-log-index.asciidoc b/docs/user/alerting/troubleshooting/event-log-index.asciidoc index 393b982b279f5..5016b6d6f19c9 100644 --- a/docs/user/alerting/troubleshooting/event-log-index.asciidoc +++ b/docs/user/alerting/troubleshooting/event-log-index.asciidoc @@ -170,7 +170,7 @@ And see the errors for the rules you might provide the next search query: } ], }, - "message": "alert executed: .index-threshold:30d856c0-b14b-11eb-9a7c-9df284da9f99: 'test'", + "message": "rule executed: .index-threshold:30d856c0-b14b-11eb-9a7c-9df284da9f99: 'test'", "error" : { "message" : "Saved object [action/ef0e2530-b14a-11eb-9a7c-9df284da9f99] not found" }, diff --git a/docs/user/commands/cli-commands.asciidoc b/docs/user/commands/cli-commands.asciidoc new file mode 100644 index 0000000000000..35a25235bc238 --- /dev/null +++ b/docs/user/commands/cli-commands.asciidoc @@ -0,0 +1,8 @@ +[[cli-commands]] +== Command line tools + +{kib} provides the following tools for configuring security and performing other tasks from the command line: + +* <> + +include::kibana-verification-code.asciidoc[] \ No newline at end of file diff --git a/docs/user/commands/kibana-verification-code.asciidoc b/docs/user/commands/kibana-verification-code.asciidoc new file mode 100644 index 0000000000000..3ad1b0da51e2b --- /dev/null +++ b/docs/user/commands/kibana-verification-code.asciidoc @@ -0,0 +1,44 @@ +[[kibana-verification-code]] +=== kibana-verification-code + +The `kibana-verification-code` tool retrieves a verification code for enrolling +a {kib} instance with a secured {es} cluster. + +[discrete] +==== Synopsis + +[source,shell] +---- +bin/kibana-verification-code +[-V, --version] [-h, --help] +---- + +[discrete] +==== Description + +Use this command to retrieve a verification code for {kib}. You enter this code +in {kib} when manually configuring a secure connection with an {es} cluster. +This tool is useful if you don’t have access to the {kib} terminal output, such +as on a hosted environment. You can connect to a machine where {kib} is +running (such as using SSH) and retrieve a verification code that you enter in +{kib}. + +IMPORTANT: You must run this tool on the same machine where {kib} is running. + +[discrete] +[[kibana-verification-code-parameters]] +==== Parameters + +`-h, --help`:: Returns all of the command parameters. + +`-V, --version`:: Displays the {kib} version number. + +[discrete] +==== Examples + +The following command retrieves a verification code for {kib}. + +[source,shell] +---- +bin/kibana-verification-code +---- \ No newline at end of file diff --git a/docs/user/index.asciidoc b/docs/user/index.asciidoc index 75d0da1c597b6..57668b3f5bccf 100644 --- a/docs/user/index.asciidoc +++ b/docs/user/index.asciidoc @@ -45,3 +45,5 @@ include::management.asciidoc[] include::api.asciidoc[] include::plugins.asciidoc[] + +include::troubleshooting.asciidoc[] diff --git a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc index 09eb304646e96..a22d46902f54c 100644 --- a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc +++ b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc @@ -1020,7 +1020,7 @@ This log message tells us that when Task Manager was running one of our rules, i For example, in this case, we’d expect to see a corresponding log line from the Alerting framework itself, saying that the rule failed. You should look in the Kibana log for a line similar to the log line below (probably shortly before the Task Manager log line): -Executing Alert "27559295-44e4-4983-aa1b-94fe043ab4f9" has resulted in Error: Unable to load resource ‘/api/something’ +Executing Rule "27559295-44e4-4983-aa1b-94fe043ab4f9" has resulted in Error: Unable to load resource ‘/api/something’ This would confirm that the error did in fact happen in the rule itself (rather than the Task Manager) and it would help us pin-point the specific ID of the rule which failed: 27559295-44e4-4983-aa1b-94fe043ab4f9 diff --git a/docs/user/setup.asciidoc b/docs/user/setup.asciidoc index 546cc8f974865..87213249e0d97 100644 --- a/docs/user/setup.asciidoc +++ b/docs/user/setup.asciidoc @@ -70,3 +70,5 @@ include::monitoring/configuring-monitoring.asciidoc[leveloffset=+1] include::monitoring/monitoring-metricbeat.asciidoc[leveloffset=+2] include::monitoring/viewing-metrics.asciidoc[leveloffset=+2] include::monitoring/monitoring-kibana.asciidoc[leveloffset=+2] + +include::commands/cli-commands.asciidoc[] diff --git a/docs/user/troubleshooting.asciidoc b/docs/user/troubleshooting.asciidoc new file mode 100644 index 0000000000000..8b32471c98d86 --- /dev/null +++ b/docs/user/troubleshooting.asciidoc @@ -0,0 +1,70 @@ +[[kibana-troubleshooting]] +== Troubleshooting + +=== Using {kib} server logs +{kib} Logs is a great way to see what's going on in your application and to debug performance issues. Navigating through a large number of generated logs can be overwhelming, and following are some techniques that you can use to optimize the process. + +Start by defining a problem area that you are interested in. For example, you might be interested in seeing how a particular {kib} Plugin is performing, so no need to gather logs for all of {kib}. Or you might want to focus on a particular feature, such as requests from the {kib} server to the {es} server. +Depending on your needs, you can configure {kib} to generate logs for a specific feature. +[source,yml] +---- +logging: + appenders: + file: + type: file + fileName: ./kibana.log + layout: + type: json + +### gather all the Kibana logs into a file +logging.root: + appenders: [file] + level: all + +### or gather a subset of the logs +logging.loggers: + ### responses to an HTTP request + - name: http.server.response + level: debug + appenders: [file] + ### result of a query to the Elasticsearch server + - name: elasticsearch.query + level: debug + appenders: [file] + ### logs generated by my plugin + - name: plugins.myPlugin + level: debug + appenders: [file] +---- +WARNING: Kibana's `file` appender is configured to produce logs in https://www.elastic.co/guide/en/ecs/master/ecs-reference.html[ECS JSON] format. It's the only format that includes the meta information necessary for https://www.elastic.co/guide/en/apm/agent/nodejs/current/log-correlation.html[log correlation] out-of-the-box. + +The next step is to define what https://www.elastic.co/observability[observability tools] are available. +For a better experience, set up an https://www.elastic.co/guide/en/apm/get-started/current/observability-integrations.html[Observability integration] provided by Elastic to debug your application with the <> +To debug something quickly without setting up additional tooling, you can work with <> + +[[debugging-logs-apm-ui]] +==== APM UI +*Prerequisites* {kib} logs are configured to be in https://www.elastic.co/guide/en/ecs/master/ecs-reference.html[ECS JSON] format to include tracing identifiers. + +To debug {kib} with the APM UI, you must set up the APM infrastructure. You can find instructions for the setup process +https://www.elastic.co/guide/en/apm/get-started/current/observability-integrations.html[on the Observability integrations page]. + +Once you set up the APM infrastructure, you can enable the APM agent and put {kib} under load to collect APM events. To analyze the collected metrics and logs, use the APM UI as demonstrated https://www.elastic.co/guide/en/kibana/master/transactions.html#transaction-trace-sample[in the docs]. + +[[plain-kibana-logs]] +==== Plain {kib} logs +*Prerequisites* {kib} logs are configured to be in https://www.elastic.co/guide/en/ecs/master/ecs-reference.html[ECS JSON] format to include tracing identifiers. + +Open {kib} Logs and search for an operation you are interested in. +For example, suppose you want to investigate the response times for queries to the `/api/telemetry/v2/clusters/_stats` {kib} endpoint. +Open Kibana Logs and search for the HTTP server response for the endpoint. It looks similar to the following (some fields are omitted for brevity). +[source,json] +---- +{ + "message":"POST /api/telemetry/v2/clusters/_stats 200 1014ms - 43.2KB", + "log":{"level":"DEBUG","logger":"http.server.response"}, + "trace":{"id":"9b99131a6f66587971ef085ef97dfd07"}, + "transaction":{"id":"d0c5bbf14f5febca"} +} +---- +You are interested in the https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html#field-trace-id[trace.id] field, which is a unique identifier of a trace. The `trace.id` provides a way to group multiple events, like transactions, which belong together. You can search for `"trace":{"id":"9b99131a6f66587971ef085ef97dfd07"}` to get all the logs that belong to the same trace. This enables you to see how many {es} requests were triggered during the `9b99131a6f66587971ef085ef97dfd07` trace, what they looked like, what {es} endpoints were hit, and so on. diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json new file mode 100644 index 0000000000000..384652f373358 --- /dev/null +++ b/nav-kibana-dev.docnav.json @@ -0,0 +1,160 @@ +{ + "mission": "Kibana Developer Guide", + "id": "kibDevDocs", + "landingPageId": "kibDevDocsWelcome", + "icon": "logoKibana", + "description": "Developer documentation for building custom Kibana plugins and extending Kibana functionality.", + "items": [ + { + "category": "Getting started", + "items": [ + { "id": "kibDevDocsWelcome" }, + { "id": "kibDevTutorialSetupDevEnv" }, + { "id": "kibHelloWorldApp" }, + { "id": "kibDevAddData" }, + { "id": "kibTroubleshooting" } + ] + }, + { + "category": "Key concepts", + "items": [ + { "id": "kibPlatformIntro" }, + { "id": "kibDevAnatomyOfAPlugin" }, + { "id": "kibDevPerformance" }, + { "id": "kibBuildingBlocks" }, + { "id": "kibDevDocsSavedObjectsIntro", "label": "Saved objects" }, + { "id": "kibDevDocsPersistableStateIntro" }, + { "id": "kibDataPlugin", "label": "Data" }, + { "id": "kibCoreLogging" }, + { "id": "kibUsageCollectionPlugin" }, + { "id": "kibDataViewsKeyConcepts" }, + { "id": "kibDevKeyConceptsNavigation" } + ] + }, + { + "category": "Tutorials", + "items": [ + { "id": "kibDevTutorialTestingPlugins" }, + { "id": "kibDevTutorialSavedObject" }, + { "id": "kibDevTutorialSubmitPullRequest" }, + { "id": "kibDevTutorialExpressions" }, + { "id": "kibDevDocsKPTTutorial" }, + { "id": "kibDevTutorialDataSearchAndSessions", "label": "data.search" }, + { "id": "kibDevTutorialDataViews" }, + { "id": "kibDevTutorialDebugging" }, + { + "id": "kibDevTutorialBuildingDistributable", + "label": "Building a Kibana distributable" + }, + { "id": "kibDevTutorialServerEndpoint" } + ] + }, + { + "category": "Contributing", + "items": [ + { "id": "kibRepoStructure" }, + { "id": "kibDevPrinciples" }, + { "id": "kibStandards" }, + { "id": "ktRFCProcess" }, + { "id": "kibBestPractices" }, + { "id": "kibStyleGuide" }, + { "id": "kibGitHub" } + ] + }, + { + "category": "Contributors Newsletters", + "items": [ + { "id": "kibNovember2021ContributorNewsletter" }, + { "id": "kibOctober2021ContributorNewsletter" }, + { "id": "kibSeptember2021ContributorNewsletter" }, + { "id": "kibAugust2021ContributorNewsletter" }, + { "id": "kibJuly2021ContributorNewsletter" }, + { "id": "kibJune2021ContributorNewsletter" }, + { "id": "kibMay2021ContributorNewsletter" }, + { "id": "kibApril2021ContributorNewsletter" }, + { "id": "kibMarch2021ContributorNewsletter" } + ] + }, + { + "category": "API documentation", + "items": [ + { "id": "kibDevDocsApiWelcome" }, + { "id": "kibDevDocsPluginDirectory" }, + { "id": "kibDevDocsDeprecationsByPlugin" }, + { "id": "kibDevDocsDeprecationsByApi" }, + { "id": "kibCorePluginApi" }, + { "id": "kibCoreApplicationPluginApi" }, + { "id": "kibCoreChromePluginApi" }, + { "id": "kibCoreHttpPluginApi" }, + { "id": "kibCoreSavedObjectsPluginApi" }, + { "id": "kibFieldFormatsPluginApi" }, + { "id": "kibDataPluginApi" }, + { "id": "kibDataAutocompletePluginApi" }, + { "id": "kibDataEnhancedPluginApi" }, + { "id": "kibDataViewsPluginApi" }, + { "id": "kibDataQueryPluginApi" }, + { "id": "kibDataSearchPluginApi" }, + { "id": "kibDataUiPluginApi" }, + { "id": "kibBfetchPluginApi" }, + { "id": "kibAlertingPluginApi" }, + { "id": "kibTaskManagerPluginApi" }, + { "id": "kibActionsPluginApi" }, + { "id": "kibEventLogPluginApi" }, + { "id": "kibTriggersActionsUiPluginApi" }, + { "id": "kibCasesPluginApi" }, + { "id": "kibChartsPluginApi" }, + { "id": "kibDashboardPluginApi" }, + { "id": "kibDevToolsPluginApi" }, + { "id": "kibDiscoverPluginApi" }, + { "id": "kibEmbeddablePluginApi" }, + { "id": "kibEncryptedSavedObjectsPluginApi" }, + { "id": "kibEnterpriseSearchPluginApi" }, + { "id": "kibEsUiSharedPluginApi" }, + { "id": "kibExpressionsPluginApi" }, + { "id": "kibFeaturesPluginApi" }, + { "id": "kibFileUploadPluginApi" }, + { "id": "kibFleetPluginApi" }, + { "id": "kibGlobalSearchPluginApi" }, + { "id": "kibHomePluginApi" }, + { "id": "kibInspectorPluginApi" }, + { "id": "kibKibanaReactPluginApi" }, + { "id": "kibKibanaUtilsPluginApi" }, + { "id": "kibLensPluginApi" }, + { "id": "kibLicenseManagementPluginApi" }, + { "id": "kibLicensingPluginApi" }, + { "id": "kibListsPluginApi" }, + { "id": "kibManagementPluginApi" }, + { "id": "kibMapsPluginApi" }, + { "id": "kibMlPluginApi" }, + { "id": "kibMonitoringPluginApi" }, + { "id": "kibNavigationPluginApi" }, + { "id": "kibNewsfeedPluginApi" }, + { "id": "kibObservabilityPluginApi" }, + { "id": "kibRemoteClustersPluginApi" }, + { "id": "kibReportingPluginApi" }, + { "id": "kibRollupPluginApi" }, + { "id": "kibRuntimeFieldsPluginApi" }, + { "id": "kibSavedObjectsManagementPluginApi" }, + { "id": "kibSavedObjectsTaggingOssPluginApi" }, + { "id": "kibSavedObjectsTaggingPluginApi" }, + { "id": "kibSavedObjectsPluginApi" }, + { "id": "kibSecuritySolutionPluginApi" }, + { "id": "kibSecurityPluginApi" }, + { "id": "kibSharePluginApi" }, + { "id": "kibSnapshotRestorePluginApi" }, + { "id": "kibSpacesPluginApi" }, + { "id": "kibStackAlertsPluginApi" }, + { "id": "kibTelemetryCollectionManagerPluginApi" }, + { "id": "kibTelemetryCollectionXpackPluginApi" }, + { "id": "kibTelemetryManagementSectionPluginApi" }, + { "id": "kibTelemetryPluginApi" }, + { "id": "kibUiActionsEnhancedPluginApi" }, + { "id": "kibUiActionsPluginApi" }, + { "id": "kibUrlForwardingPluginApi" }, + { "id": "kibUsageCollectionPluginApi" }, + { "id": "kibVisTypeTimeseriesPluginApi" }, + { "id": "kibVisualizationsPluginApi" } + ] + } + ] +} diff --git a/package.json b/package.json index 6b7d6662eb70b..871e467394e52 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "**/react-syntax-highlighter": "^15.3.1", "**/react-syntax-highlighter/**/highlight.js": "^10.4.1", "**/trim": "1.0.1", - "**/typescript": "4.3.5", + "**/typescript": "4.5.3", "**/underscore": "^1.13.1", "globby/fast-glob": "3.2.7" }, @@ -100,16 +100,15 @@ "@dnd-kit/core": "^3.1.1", "@dnd-kit/sortable": "^4.0.0", "@dnd-kit/utilities": "^2.0.0", - "@elastic/apm-rum": "^5.9.1", - "@elastic/apm-rum-react": "^1.3.1", + "@elastic/apm-rum": "^5.10.0", + "@elastic/apm-rum-react": "^1.3.2", "@elastic/apm-synthtrace": "link:bazel-bin/packages/elastic-apm-synthtrace", "@elastic/charts": "40.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.35", "@elastic/ems-client": "8.0.0", - "@elastic/eui": "41.0.0", + "@elastic/eui": "41.2.3", "@elastic/filesaver": "1.1.2", - "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", "@elastic/react-search-ui": "^1.6.0", @@ -196,8 +195,10 @@ "archiver": "^5.2.0", "axios": "^0.21.1", "base64-js": "^1.3.1", + "bitmap-sdf": "^1.0.3", "brace": "0.11.1", "broadcast-channel": "^4.7.0", + "canvg": "^3.0.9", "chalk": "^4.1.0", "cheerio": "^1.0.0-rc.10", "chokidar": "^3.4.3", @@ -225,7 +226,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.25.0", + "elastic-apm-node": "^3.26.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "expiry-js": "0.1.7", @@ -368,7 +369,7 @@ "redux-thunks": "^1.0.0", "regenerator-runtime": "^0.13.3", "remark-parse": "^8.0.3", - "remark-stringify": "^9.0.0", + "remark-stringify": "^8.0.3", "require-in-the-middle": "^5.1.0", "reselect": "^4.0.0", "resize-observer-polyfill": "^1.5.1", @@ -520,7 +521,6 @@ "@types/ejs": "^3.0.6", "@types/elastic__apm-synthtrace": "link:bazel-bin/packages/elastic-apm-synthtrace/npm_module_types", "@types/elastic__datemath": "link:bazel-bin/packages/elastic-datemath/npm_module_types", - "@types/elasticsearch": "^5.0.33", "@types/enzyme": "^3.10.8", "@types/eslint": "^7.28.0", "@types/express": "^4.17.13", @@ -555,7 +555,7 @@ "@types/js-levenshtein": "^1.1.0", "@types/js-search": "^1.4.0", "@types/js-yaml": "^3.11.1", - "@types/jsdom": "^16.2.3", + "@types/jsdom": "^16.2.13", "@types/json-stable-stringify": "^1.0.32", "@types/json5": "^0.0.30", "@types/kbn__ace": "link:bazel-bin/packages/kbn-ace/npm_module_types", @@ -567,7 +567,10 @@ "@types/kbn__config": "link:bazel-bin/packages/kbn-config/npm_module_types", "@types/kbn__config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module_types", "@types/kbn__crypto": "link:bazel-bin/packages/kbn-crypto/npm_module_types", + "@types/kbn__dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module_types", "@types/kbn__docs-utils": "link:bazel-bin/packages/kbn-docs-utils/npm_module_types", + "@types/kbn__es-archiver": "link:bazel-bin/packages/kbn-es-archiver/npm_module_types", + "@types/kbn__es-query": "link:bazel-bin/packages/kbn-es-query/npm_module_types", "@types/kbn__i18n": "link:bazel-bin/packages/kbn-i18n/npm_module_types", "@types/kbn__i18n-react": "link:bazel-bin/packages/kbn-i18n-react/npm_module_types", "@types/license-checker": "15.0.0", @@ -833,7 +836,7 @@ "ts-loader": "^7.0.5", "ts-morph": "^11.0.0", "tsd": "^0.13.1", - "typescript": "4.3.5", + "typescript": "4.5.3", "unlazy-loader": "^0.1.3", "url-loader": "^2.2.0", "val-loader": "^1.1.1", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index aa90c3c122171..5fdaa9931bc4d 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -86,7 +86,10 @@ filegroup( "//packages/kbn-config:build_types", "//packages/kbn-config-schema:build_types", "//packages/kbn-crypto:build_types", + "//packages/kbn-dev-utils:build_types", "//packages/kbn-docs-utils:build_types", + "//packages/kbn-es-archiver:build_types", + "//packages/kbn-es-query:build_types", "//packages/kbn-i18n:build_types", "//packages/kbn-i18n-react:build_types", ], diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts index a7a826d144d0e..e0a48fdcf2b89 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts @@ -15,6 +15,9 @@ export type ApmApplicationMetricFields = Partial<{ 'system.cpu.total.norm.pct': number; 'system.process.memory.rss.bytes': number; 'system.process.cpu.total.norm.pct': number; + 'jvm.memory.heap.used': number; + 'jvm.memory.non_heap.used': number; + 'jvm.thread.count': number; }>; export type ApmUserAgentFields = Partial<{ diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/service.ts b/packages/elastic-apm-synthtrace/src/lib/apm/service.ts index 16917821c7ee4..d55f60d86e4db 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/service.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/service.ts @@ -15,6 +15,7 @@ export class Service extends Entity { return new Instance({ ...this.fields, ['service.node.name']: instanceName, + 'host.name': instanceName, 'container.id': instanceName, }); } diff --git a/packages/elastic-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts b/packages/elastic-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts index b38d34266f3ac..a78f1ec987bcf 100644 --- a/packages/elastic-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts +++ b/packages/elastic-apm-synthtrace/src/test/scenarios/01_simple_trace.test.ts @@ -70,6 +70,7 @@ describe('simple trace', () => { 'agent.name': 'java', 'container.id': 'instance-1', 'event.outcome': 'success', + 'host.name': 'instance-1', 'processor.event': 'transaction', 'processor.name': 'transaction', 'service.environment': 'production', @@ -92,6 +93,7 @@ describe('simple trace', () => { 'agent.name': 'java', 'container.id': 'instance-1', 'event.outcome': 'success', + 'host.name': 'instance-1', 'parent.id': '0000000000000300', 'processor.event': 'span', 'processor.name': 'transaction', diff --git a/packages/elastic-apm-synthtrace/src/test/scenarios/__snapshots__/01_simple_trace.test.ts.snap b/packages/elastic-apm-synthtrace/src/test/scenarios/__snapshots__/01_simple_trace.test.ts.snap index 76a76d41ec81d..1a5fca39e9fd9 100644 --- a/packages/elastic-apm-synthtrace/src/test/scenarios/__snapshots__/01_simple_trace.test.ts.snap +++ b/packages/elastic-apm-synthtrace/src/test/scenarios/__snapshots__/01_simple_trace.test.ts.snap @@ -7,6 +7,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -24,6 +25,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000000", "processor.event": "span", "processor.name": "transaction", @@ -43,6 +45,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -60,6 +63,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000004", "processor.event": "span", "processor.name": "transaction", @@ -79,6 +83,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -96,6 +101,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000008", "processor.event": "span", "processor.name": "transaction", @@ -115,6 +121,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -132,6 +139,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000012", "processor.event": "span", "processor.name": "transaction", @@ -151,6 +159,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -168,6 +177,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000016", "processor.event": "span", "processor.name": "transaction", @@ -187,6 +197,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -204,6 +215,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000020", "processor.event": "span", "processor.name": "transaction", @@ -223,6 +235,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -240,6 +253,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000024", "processor.event": "span", "processor.name": "transaction", @@ -259,6 +273,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -276,6 +291,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000028", "processor.event": "span", "processor.name": "transaction", @@ -295,6 +311,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -312,6 +329,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000032", "processor.event": "span", "processor.name": "transaction", @@ -331,6 +349,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -348,6 +367,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000036", "processor.event": "span", "processor.name": "transaction", @@ -367,6 +387,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -384,6 +405,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000040", "processor.event": "span", "processor.name": "transaction", @@ -403,6 +425,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -420,6 +443,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000044", "processor.event": "span", "processor.name": "transaction", @@ -439,6 +463,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -456,6 +481,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000048", "processor.event": "span", "processor.name": "transaction", @@ -475,6 +501,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -492,6 +519,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000052", "processor.event": "span", "processor.name": "transaction", @@ -511,6 +539,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "processor.event": "transaction", "processor.name": "transaction", "service.environment": "production", @@ -528,6 +557,7 @@ Array [ "agent.name": "java", "container.id": "instance-1", "event.outcome": "success", + "host.name": "instance-1", "parent.id": "0000000000000056", "processor.event": "span", "processor.name": "transaction", diff --git a/packages/elastic-eslint-config-kibana/react.js b/packages/elastic-eslint-config-kibana/react.js index 29000bdb15684..0b1cce15de9ad 100644 --- a/packages/elastic-eslint-config-kibana/react.js +++ b/packages/elastic-eslint-config-kibana/react.js @@ -1,5 +1,5 @@ const semver = require('semver'); -const { kibanaPackageJson: PKG } = require('@kbn/dev-utils'); +const { kibanaPackageJson: PKG } = require('@kbn/utils'); module.exports = { plugins: [ diff --git a/packages/elastic-eslint-config-kibana/typescript.js b/packages/elastic-eslint-config-kibana/typescript.js index 1a0ef81ae2f1e..3ada725cb1805 100644 --- a/packages/elastic-eslint-config-kibana/typescript.js +++ b/packages/elastic-eslint-config-kibana/typescript.js @@ -4,7 +4,7 @@ // as this package was moved from typescript-eslint-parser to @typescript-eslint/parser const semver = require('semver'); -const { kibanaPackageJson: PKG } = require('@kbn/dev-utils'); +const { kibanaPackageJson: PKG } = require('@kbn/utils'); const eslintConfigPrettierTypescriptEslintRules = require('eslint-config-prettier/@typescript-eslint').rules; diff --git a/packages/kbn-cli-dev-mode/BUILD.bazel b/packages/kbn-cli-dev-mode/BUILD.bazel index dfb441dffc6ef..cdc40e85c972a 100644 --- a/packages/kbn-cli-dev-mode/BUILD.bazel +++ b/packages/kbn-cli-dev-mode/BUILD.bazel @@ -50,7 +50,7 @@ RUNTIME_DEPS = [ TYPES_DEPS = [ "//packages/kbn-config:npm_module_types", "//packages/kbn-config-schema:npm_module_types", - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "//packages/kbn-logging", "//packages/kbn-optimizer", "//packages/kbn-server-http-tools", 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 e5e009e51e69e..0066644d0825a 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 @@ -8,11 +8,9 @@ import Path from 'path'; import * as Rx from 'rxjs'; -import { - REPO_ROOT, - createAbsolutePathSerializer, - createAnyInstanceSerializer, -} from '@kbn/dev-utils'; +import { createAbsolutePathSerializer, createAnyInstanceSerializer } from '@kbn/dev-utils'; + +import { REPO_ROOT } from '@kbn/utils'; import { TestLog } from './log'; import { CliDevMode, SomeCliArgs } from './cli_dev_mode'; 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 2396b316aa3a2..9cf688b675e67 100644 --- a/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts +++ b/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts @@ -22,7 +22,8 @@ import { takeUntil, } from 'rxjs/operators'; import { CliArgs } from '@kbn/config'; -import { REPO_ROOT, CiStatsReporter } from '@kbn/dev-utils'; +import { CiStatsReporter } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Log, CliLog } from './log'; import { Optimizer } from './optimizer'; diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts index 06ded8d8bf526..25bc59bf78458 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts @@ -8,7 +8,8 @@ import Path from 'path'; -import { REPO_ROOT, createAbsolutePathSerializer } from '@kbn/dev-utils'; +import { createAbsolutePathSerializer } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { getServerWatchPaths } from './get_server_watch_paths'; diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts index f075dc806b6ec..acfc9aeecdc80 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts @@ -9,7 +9,7 @@ import Path from 'path'; import Fs from 'fs'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; interface Options { pluginPaths: string[]; diff --git a/packages/kbn-config-schema/src/byte_size_value/index.test.ts b/packages/kbn-config-schema/src/byte_size_value/index.test.ts index a5d0142853416..7a2e3a5d6cb0f 100644 --- a/packages/kbn-config-schema/src/byte_size_value/index.test.ts +++ b/packages/kbn-config-schema/src/byte_size_value/index.test.ts @@ -30,6 +30,11 @@ describe('parsing units', () => { expect(ByteSizeValue.parse('1gb').getValueInBytes()).toBe(1073741824); }); + test('case insensitive units', () => { + expect(ByteSizeValue.parse('1KB').getValueInBytes()).toBe(1024); + expect(ByteSizeValue.parse('1Mb').getValueInBytes()).toBe(1024 * 1024); + }); + test('throws an error when unsupported unit specified', () => { expect(() => ByteSizeValue.parse('1tb')).toThrowErrorMatchingInlineSnapshot( `"Failed to parse value as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."` diff --git a/packages/kbn-config-schema/src/byte_size_value/index.ts b/packages/kbn-config-schema/src/byte_size_value/index.ts index fb90bd70ed5c6..6fabe35b30024 100644 --- a/packages/kbn-config-schema/src/byte_size_value/index.ts +++ b/packages/kbn-config-schema/src/byte_size_value/index.ts @@ -22,7 +22,7 @@ function renderUnit(value: number, unit: string) { export class ByteSizeValue { public static parse(text: string): ByteSizeValue { - const match = /([1-9][0-9]*)(b|kb|mb|gb)/.exec(text); + const match = /([1-9][0-9]*)(b|kb|mb|gb)/i.exec(text); if (!match) { const number = Number(text); if (typeof number !== 'number' || isNaN(number)) { @@ -35,7 +35,7 @@ export class ByteSizeValue { } const value = parseInt(match[1], 10); - const unit = match[2]; + const unit = match[2].toLowerCase(); return new ByteSizeValue(value * unitMultiplier[unit]); } diff --git a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts index 70945b2d96b32..3f84eed867655 100644 --- a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts +++ b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts @@ -116,6 +116,36 @@ describe('applyDeprecations', () => { expect(migrated).toEqual({ foo: 'bar', newname: 'renamed' }); }); + it('nested properties take into account if their parents are empty objects, and remove them if so', () => { + const initialConfig = { + foo: 'bar', + deprecated: { nested: 'deprecated' }, + nested: { + from: { + rename: 'renamed', + }, + to: { + keep: 'keep', + }, + }, + }; + + const { config: migrated } = applyDeprecations(initialConfig, [ + wrapHandler(deprecations.unused('deprecated.nested')), + wrapHandler(deprecations.rename('nested.from.rename', 'nested.to.renamed')), + ]); + + expect(migrated).toStrictEqual({ + foo: 'bar', + nested: { + to: { + keep: 'keep', + renamed: 'renamed', + }, + }, + }); + }); + it('does not alter the initial config', () => { 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 11b35840969d0..9b0c409204414 100644 --- a/packages/kbn-config/src/deprecation/apply_deprecations.ts +++ b/packages/kbn-config/src/deprecation/apply_deprecations.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ -import { cloneDeep, unset } from 'lodash'; +import { cloneDeep } from 'lodash'; import { set } from '@elastic/safer-lodash-set'; import type { AddConfigDeprecation, ChangedDeprecatedPaths, ConfigDeprecationWithContext, } from './types'; +import { unsetAndCleanEmptyParent } from './unset_and_clean_empty_parent'; const noopAddDeprecationFactory: () => AddConfigDeprecation = () => () => undefined; @@ -45,7 +46,7 @@ export const applyDeprecations = ( if (commands.unset) { changedPaths.unset.push(...commands.unset.map((c) => c.path)); commands.unset.forEach(function ({ path: commandPath }) { - unset(result, commandPath); + unsetAndCleanEmptyParent(result, commandPath); }); } } diff --git a/packages/kbn-config/src/deprecation/types.ts b/packages/kbn-config/src/deprecation/types.ts index 7b1eb4a0ea6c1..6abe4cd94a6fb 100644 --- a/packages/kbn-config/src/deprecation/types.ts +++ b/packages/kbn-config/src/deprecation/types.ts @@ -186,6 +186,25 @@ export interface ConfigDeprecationFactory { * rename('oldKey', 'newKey'), * ] * ``` + * + * @remarks + * If the oldKey is a nested property and it's the last property in an object, it may remove any empty-object parent keys. + * ``` + * // Original object + * { + * a: { + * b: { c: 1 }, + * d: { e: 1 } + * } + * } + * + * // If rename('a.b.c', 'a.d.c'), the resulting object removes the entire "a.b" tree because "c" was the last property in that branch + * { + * a: { + * d: { c: 1, e: 1 } + * } + * } + * ``` */ rename( oldKey: string, @@ -207,6 +226,25 @@ export interface ConfigDeprecationFactory { * renameFromRoot('oldplugin.key', 'newplugin.key'), * ] * ``` + * + * @remarks + * If the oldKey is a nested property and it's the last property in an object, it may remove any empty-object parent keys. + * ``` + * // Original object + * { + * a: { + * b: { c: 1 }, + * d: { e: 1 } + * } + * } + * + * // If renameFromRoot('a.b.c', 'a.d.c'), the resulting object removes the entire "a.b" tree because "c" was the last property in that branch + * { + * a: { + * d: { c: 1, e: 1 } + * } + * } + * ``` */ renameFromRoot( oldKey: string, @@ -225,6 +263,25 @@ export interface ConfigDeprecationFactory { * unused('deprecatedKey'), * ] * ``` + * + * @remarks + * If the path is a nested property and it's the last property in an object, it may remove any empty-object parent keys. + * ``` + * // Original object + * { + * a: { + * b: { c: 1 }, + * d: { e: 1 } + * } + * } + * + * // If unused('a.b.c'), the resulting object removes the entire "a.b" tree because "c" was the last property in that branch + * { + * a: { + * d: { e: 1 } + * } + * } + * ``` */ unused(unusedKey: string, details?: Partial): ConfigDeprecation; @@ -242,6 +299,25 @@ export interface ConfigDeprecationFactory { * unusedFromRoot('somepath.deprecatedProperty'), * ] * ``` + * + * @remarks + * If the path is a nested property and it's the last property in an object, it may remove any empty-object parent keys. + * ``` + * // Original object + * { + * a: { + * b: { c: 1 }, + * d: { e: 1 } + * } + * } + * + * // If unused('a.b.c'), the resulting object removes the entire "a.b" tree because "c" was the last property in that branch + * { + * a: { + * d: { e: 1 } + * } + * } + * ``` */ unusedFromRoot(unusedKey: string, details?: Partial): ConfigDeprecation; } diff --git a/packages/kbn-config/src/deprecation/unset_and_clean_empty_parent.test.ts b/packages/kbn-config/src/deprecation/unset_and_clean_empty_parent.test.ts new file mode 100644 index 0000000000000..115730c106137 --- /dev/null +++ b/packages/kbn-config/src/deprecation/unset_and_clean_empty_parent.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { unsetAndCleanEmptyParent } from './unset_and_clean_empty_parent'; + +describe('unsetAndcleanEmptyParent', () => { + test('unsets the property of the root object, and returns an empty root object', () => { + const config = { toRemove: 'toRemove' }; + unsetAndCleanEmptyParent(config, 'toRemove'); + expect(config).toStrictEqual({}); + }); + + test('unsets a nested property of the root object, and removes the empty parent property', () => { + const config = { nestedToRemove: { toRemove: 'toRemove' } }; + unsetAndCleanEmptyParent(config, 'nestedToRemove.toRemove'); + expect(config).toStrictEqual({}); + }); + + describe('Navigating to parent known issue: Array paths', () => { + // We navigate to the parent property by splitting the "." and dropping the last item in the path. + // This means that paths that are declared as prop1[idx] cannot apply the parent's cleanup logic. + // The use cases for this are quite limited, so we'll accept it as a documented limitation. + + test('does not remove a parent array when the index is specified with square brackets', () => { + const config = { nestedToRemove: [{ toRemove: 'toRemove' }] }; + unsetAndCleanEmptyParent(config, 'nestedToRemove[0].toRemove'); + expect(config).toStrictEqual({ nestedToRemove: [{}] }); + }); + + test('removes a parent array when the index is specified with dots', () => { + const config = { nestedToRemove: [{ toRemove: 'toRemove' }] }; + unsetAndCleanEmptyParent(config, 'nestedToRemove.0.toRemove'); + expect(config).toStrictEqual({}); + }); + }); +}); diff --git a/packages/kbn-config/src/deprecation/unset_and_clean_empty_parent.ts b/packages/kbn-config/src/deprecation/unset_and_clean_empty_parent.ts new file mode 100644 index 0000000000000..c5f5e5951adc4 --- /dev/null +++ b/packages/kbn-config/src/deprecation/unset_and_clean_empty_parent.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { get, unset } from 'lodash'; + +/** + * Unsets the path and checks if the parent property is an empty object. + * If so, it removes the property from the config object (mutation is applied). + * + * @internal + */ +export const unsetAndCleanEmptyParent = ( + config: Record, + path: string | string[] +): void => { + // 1. Unset the provided path + const didUnset = unset(config, path); + + // Check if the unset actually removed anything. + // This way we avoid some CPU cycles when the previous action didn't apply any changes. + if (didUnset) { + // 2. Check if the parent property in the resulting object is an empty object + const pathArray = Array.isArray(path) ? path : path.split('.'); + const parentPath = pathArray.slice(0, -1); + if (parentPath.length === 0) { + return; + } + const parentObj = get(config, parentPath); + if ( + typeof parentObj === 'object' && + parentObj !== null && + Object.keys(parentObj).length === 0 + ) { + unsetAndCleanEmptyParent(config, parentPath); + } + } +}; diff --git a/packages/kbn-crypto/BUILD.bazel b/packages/kbn-crypto/BUILD.bazel index 81ee6d770103c..f71c8b866fd5d 100644 --- a/packages/kbn-crypto/BUILD.bazel +++ b/packages/kbn-crypto/BUILD.bazel @@ -34,7 +34,7 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "@npm//@types/flot", "@npm//@types/jest", "@npm//@types/node", diff --git a/packages/kbn-dev-utils/BUILD.bazel b/packages/kbn-dev-utils/BUILD.bazel index 4fd99e0144cb6..89df1870a3cec 100644 --- a/packages/kbn-dev-utils/BUILD.bazel +++ b/packages/kbn-dev-utils/BUILD.bazel @@ -1,9 +1,10 @@ -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") +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") PKG_BASE_NAME = "kbn-dev-utils" PKG_REQUIRE_NAME = "@kbn/dev-utils" +TYPES_PKG_REQUIRE_NAME = "@types/kbn__dev-utils" SOURCE_FILES = glob( [ @@ -43,7 +44,6 @@ NPM_MODULE_EXTRA_FILES = [ ] RUNTIME_DEPS = [ - "//packages/kbn-std", "//packages/kbn-utils", "@npm//@babel/core", "@npm//axios", @@ -66,7 +66,6 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-std", "//packages/kbn-utils", "@npm//@babel/parser", "@npm//@babel/types", @@ -124,7 +123,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], + deps = RUNTIME_DEPS + [":target_node"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) @@ -143,3 +142,20 @@ filegroup( ], visibility = ["//visibility:public"], ) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = TYPES_PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [ + ":npm_module_types", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-dev-utils/package.json b/packages/kbn-dev-utils/package.json index 9d6e6dde86fac..ab4f489e7d345 100644 --- a/packages/kbn-dev-utils/package.json +++ b/packages/kbn-dev-utils/package.json @@ -4,7 +4,6 @@ "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target_node/index.js", - "types": "./target_types/index.d.ts", "kibana": { "devOnly": true } diff --git a/packages/kbn-dev-utils/src/index.ts b/packages/kbn-dev-utils/src/index.ts index 381e99ac677f5..9b207ad9e9966 100644 --- a/packages/kbn-dev-utils/src/index.ts +++ b/packages/kbn-dev-utils/src/index.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -export * from '@kbn/utils'; export { withProcRunner, ProcRunner } from './proc_runner'; export * from './tooling_log'; export * from './serializers'; diff --git a/packages/kbn-dev-utils/src/tooling_log/__snapshots__/tooling_log_text_writer.test.ts.snap b/packages/kbn-dev-utils/src/tooling_log/__snapshots__/tooling_log_text_writer.test.ts.snap index 7ff982acafbe4..5fa074d4c7739 100644 --- a/packages/kbn-dev-utils/src/tooling_log/__snapshots__/tooling_log_text_writer.test.ts.snap +++ b/packages/kbn-dev-utils/src/tooling_log/__snapshots__/tooling_log_text_writer.test.ts.snap @@ -170,6 +170,14 @@ exports[`level:warning/type:warning snapshots: output 1`] = ` " `; +exports[`never ignores write messages from the kibana elasticsearch.deprecation logger context 1`] = ` +" │[elasticsearch.deprecation] + │{ foo: { bar: { '1': [Array] } }, bar: { bar: { '1': [Array] } } } + │ + │Infinity +" +`; + exports[`throws error if created with invalid level 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,success,info,debug,verbose)"`; exports[`throws error if writeTo config is not defined or doesn't have a write method 1`] = `"ToolingLogTextWriter requires the \`writeTo\` option be set to a stream (like process.stdout)"`; diff --git a/packages/kbn-dev-utils/src/tooling_log/tooling_log_text_writer.test.ts b/packages/kbn-dev-utils/src/tooling_log/tooling_log_text_writer.test.ts index b4668f29b6e21..fbccfdcdf6ac0 100644 --- a/packages/kbn-dev-utils/src/tooling_log/tooling_log_text_writer.test.ts +++ b/packages/kbn-dev-utils/src/tooling_log/tooling_log_text_writer.test.ts @@ -88,3 +88,55 @@ it('formats %s patterns and indents multi-line messages correctly', () => { const output = write.mock.calls.reduce((acc, chunk) => `${acc}${chunk}`, ''); expect(output).toMatchSnapshot(); }); + +it('does not write messages from sources in ignoreSources', () => { + const write = jest.fn(); + const writer = new ToolingLogTextWriter({ + ignoreSources: ['myIgnoredSource'], + level: 'debug', + writeTo: { + write, + }, + }); + + writer.write({ + source: 'myIgnoredSource', + type: 'success', + indent: 10, + args: [ + '%s\n%O\n\n%d', + 'foo bar', + { foo: { bar: { 1: [1, 2, 3] } }, bar: { bar: { 1: [1, 2, 3] } } }, + Infinity, + ], + }); + + const output = write.mock.calls.reduce((acc, chunk) => `${acc}${chunk}`, ''); + expect(output).toEqual(''); +}); + +it('never ignores write messages from the kibana elasticsearch.deprecation logger context', () => { + const write = jest.fn(); + const writer = new ToolingLogTextWriter({ + ignoreSources: ['myIgnoredSource'], + level: 'debug', + writeTo: { + write, + }, + }); + + writer.write({ + source: 'myIgnoredSource', + type: 'write', + indent: 10, + args: [ + '%s\n%O\n\n%d', + '[elasticsearch.deprecation]', + { foo: { bar: { 1: [1, 2, 3] } }, bar: { bar: { 1: [1, 2, 3] } } }, + Infinity, + ], + }); + + const output = write.mock.calls.reduce((acc, chunk) => `${acc}${chunk}`, ''); + expect(output).toMatchSnapshot(); +}); diff --git a/packages/kbn-dev-utils/src/tooling_log/tooling_log_text_writer.ts b/packages/kbn-dev-utils/src/tooling_log/tooling_log_text_writer.ts index 660dae3fa1f55..4fe33241cf77e 100644 --- a/packages/kbn-dev-utils/src/tooling_log/tooling_log_text_writer.ts +++ b/packages/kbn-dev-utils/src/tooling_log/tooling_log_text_writer.ts @@ -92,7 +92,15 @@ export class ToolingLogTextWriter implements Writer { } if (this.ignoreSources && msg.source && this.ignoreSources.includes(msg.source)) { - return false; + if (msg.type === 'write') { + const txt = format(msg.args[0], ...msg.args.slice(1)); + // Ensure that Elasticsearch deprecation log messages from Kibana aren't ignored + if (!/elasticsearch\.deprecation/.test(txt)) { + return false; + } + } else { + return false; + } } const prefix = has(MSG_PREFIXES, msg.type) ? MSG_PREFIXES[msg.type] : ''; diff --git a/packages/kbn-docs-utils/BUILD.bazel b/packages/kbn-docs-utils/BUILD.bazel index 37e5bb06377cc..edfd3ee96c181 100644 --- a/packages/kbn-docs-utils/BUILD.bazel +++ b/packages/kbn-docs-utils/BUILD.bazel @@ -38,7 +38,7 @@ RUNTIME_DEPS = [ TYPES_DEPS = [ "//packages/kbn-config:npm_module_types", - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "//packages/kbn-utils", "@npm//ts-morph", "@npm//@types/dedent", diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts b/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts index 2e4ce08540714..3c9137b260a3e 100644 --- a/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts +++ b/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts @@ -9,7 +9,8 @@ import Fs from 'fs'; import Path from 'path'; -import { REPO_ROOT, run, CiStatsReporter, createFlagError } from '@kbn/dev-utils'; +import { run, CiStatsReporter, createFlagError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Project } from 'ts-morph'; import { writePluginDocs } from './mdx/write_plugin_mdx_docs'; @@ -241,7 +242,7 @@ export function runBuildApiDocsCli() { boolean: ['references'], help: ` --plugin Optionally, run for only a specific plugin - --stats Optionally print API stats. Must be one or more of: any, comments or exports. + --stats Optionally print API stats. Must be one or more of: any, comments or exports. --references Collect references for API items `, }, diff --git a/packages/kbn-docs-utils/src/api_docs/find_plugins.ts b/packages/kbn-docs-utils/src/api_docs/find_plugins.ts index 78cba3f3a9476..774452a6f1f9f 100644 --- a/packages/kbn-docs-utils/src/api_docs/find_plugins.ts +++ b/packages/kbn-docs-utils/src/api_docs/find_plugins.ts @@ -12,7 +12,8 @@ import globby from 'globby'; import loadJsonFile from 'load-json-file'; import { getPluginSearchPaths } from '@kbn/config'; -import { simpleKibanaPlatformPluginDiscovery, REPO_ROOT } from '@kbn/dev-utils'; +import { simpleKibanaPlatformPluginDiscovery } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { ApiScope, PluginOrPackage } from './types'; export function findPlugins(): PluginOrPackage[] { diff --git a/packages/kbn-es-archiver/BUILD.bazel b/packages/kbn-es-archiver/BUILD.bazel index 2dc311ed74406..da8aaf913ab67 100644 --- a/packages/kbn-es-archiver/BUILD.bazel +++ b/packages/kbn-es-archiver/BUILD.bazel @@ -1,9 +1,10 @@ -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") +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") PKG_BASE_NAME = "kbn-es-archiver" PKG_REQUIRE_NAME = "@kbn/es-archiver" +TYPES_PKG_REQUIRE_NAME = "@types/kbn__es-archiver" SOURCE_FILES = glob( [ @@ -43,7 +44,7 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "//packages/kbn-test", "//packages/kbn-utils", "@npm//@elastic/elasticsearch", @@ -90,7 +91,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], + deps = RUNTIME_DEPS + [":target_node"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) @@ -109,3 +110,20 @@ filegroup( ], visibility = ["//visibility:public"], ) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = TYPES_PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [ + ":npm_module_types", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json index 0cce08eaf0352..bff3990a0c1bc 100644 --- a/packages/kbn-es-archiver/package.json +++ b/packages/kbn-es-archiver/package.json @@ -4,7 +4,6 @@ "license": "SSPL-1.0 OR Elastic License 2.0", "private": "true", "main": "target_node/index.js", - "types": "target_types/index.d.ts", "kibana": { "devOnly": true } diff --git a/packages/kbn-es-archiver/src/actions/load.ts b/packages/kbn-es-archiver/src/actions/load.ts index 0a7235c566b52..0a318f895deb3 100644 --- a/packages/kbn-es-archiver/src/actions/load.ts +++ b/packages/kbn-es-archiver/src/actions/load.ts @@ -9,7 +9,8 @@ import { resolve, relative } from 'path'; import { createReadStream } from 'fs'; import { Readable } from 'stream'; -import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { KbnClient } from '@kbn/test'; import type { Client } from '@elastic/elasticsearch'; import { createPromiseFromStreams, concatStreamProviders } from '@kbn/utils'; @@ -85,15 +86,17 @@ export async function loadAction({ progress.deactivate(); const result = stats.toJSON(); + const indicesWithDocs: string[] = []; for (const [index, { docs }] of Object.entries(result)) { if (docs && docs.indexed > 0) { log.info('[%s] Indexed %d docs into %j', name, docs.indexed, index); + indicesWithDocs.push(index); } } await client.indices.refresh( { - index: '_all', + index: indicesWithDocs.join(','), allow_no_indices: true, }, { diff --git a/packages/kbn-es-archiver/src/actions/rebuild_all.ts b/packages/kbn-es-archiver/src/actions/rebuild_all.ts index 360fdb438f2db..27fcae0c7cec5 100644 --- a/packages/kbn-es-archiver/src/actions/rebuild_all.ts +++ b/packages/kbn-es-archiver/src/actions/rebuild_all.ts @@ -10,8 +10,8 @@ import { resolve, relative } from 'path'; import { Stats, createReadStream, createWriteStream } from 'fs'; import { stat, rename } from 'fs/promises'; import { Readable, Writable } from 'stream'; -import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; -import { createPromiseFromStreams } from '@kbn/utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { createPromiseFromStreams, REPO_ROOT } from '@kbn/utils'; import { prioritizeMappings, readDirectory, diff --git a/packages/kbn-es-archiver/src/actions/save.ts b/packages/kbn-es-archiver/src/actions/save.ts index 9cb5be05ac060..e5e3f06b8436d 100644 --- a/packages/kbn-es-archiver/src/actions/save.ts +++ b/packages/kbn-es-archiver/src/actions/save.ts @@ -10,8 +10,8 @@ import { resolve, relative } from 'path'; import { createWriteStream, mkdirSync } from 'fs'; import { Readable, Writable } from 'stream'; import type { Client } from '@elastic/elasticsearch'; -import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; -import { createListStream, createPromiseFromStreams } from '@kbn/utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { createListStream, createPromiseFromStreams, REPO_ROOT } from '@kbn/utils'; import { createStats, diff --git a/packages/kbn-es-archiver/src/actions/unload.ts b/packages/kbn-es-archiver/src/actions/unload.ts index 1c5f4cd5d7d03..22830b7289174 100644 --- a/packages/kbn-es-archiver/src/actions/unload.ts +++ b/packages/kbn-es-archiver/src/actions/unload.ts @@ -10,9 +10,9 @@ import { resolve, relative } from 'path'; import { createReadStream } from 'fs'; import { Readable, Writable } from 'stream'; import type { Client } from '@elastic/elasticsearch'; -import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; -import { createPromiseFromStreams } from '@kbn/utils'; +import { createPromiseFromStreams, REPO_ROOT } from '@kbn/utils'; import { isGzip, diff --git a/packages/kbn-es-archiver/src/es_archiver.ts b/packages/kbn-es-archiver/src/es_archiver.ts index 354197a98fa46..e13e20f25a703 100644 --- a/packages/kbn-es-archiver/src/es_archiver.ts +++ b/packages/kbn-es-archiver/src/es_archiver.ts @@ -10,7 +10,8 @@ import Fs from 'fs'; import Path from 'path'; import type { Client } from '@elastic/elasticsearch'; -import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { KbnClient } from '@kbn/test'; import { diff --git a/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.test.ts b/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.test.ts index ae21649690a99..2590074a25411 100644 --- a/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.test.ts +++ b/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.test.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ +import { ToolingLog } from '@kbn/dev-utils'; + import { createListStream, createPromiseFromStreams, createConcatStream, createMapStream, - ToolingLog, -} from '@kbn/dev-utils'; +} from '@kbn/utils'; import { createGenerateDocRecordsStream } from './generate_doc_records_stream'; import { Progress } from '../progress'; diff --git a/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.test.ts b/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.test.ts index bcf28a4976a1c..9c0ff4a8f91ec 100644 --- a/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.test.ts +++ b/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.test.ts @@ -6,12 +6,9 @@ * Side Public License, v 1. */ -import { - createListStream, - createPromiseFromStreams, - ToolingLog, - createRecursiveSerializer, -} from '@kbn/dev-utils'; +import { ToolingLog, createRecursiveSerializer } from '@kbn/dev-utils'; + +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { Progress } from '../progress'; import { createIndexDocRecordsStream } from './index_doc_records_stream'; diff --git a/packages/kbn-es-query/BUILD.bazel b/packages/kbn-es-query/BUILD.bazel index 70d8d659c99fe..86f3d3ccc13a8 100644 --- a/packages/kbn-es-query/BUILD.bazel +++ b/packages/kbn-es-query/BUILD.bazel @@ -1,10 +1,11 @@ -load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@npm//@bazel/typescript:index.bzl", "ts_config") load("@npm//peggy:index.bzl", "peggy") -load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") -load("//src/dev/bazel:index.bzl", "jsts_transpiler") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") PKG_BASE_NAME = "kbn-es-query" PKG_REQUIRE_NAME = "@kbn/es-query" +TYPES_PKG_REQUIRE_NAME = "@types/kbn__es-query" SOURCE_FILES = glob( [ @@ -104,7 +105,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES + [":grammar"], - deps = RUNTIME_DEPS + [":target_node", ":target_web", ":tsc_types"], + deps = RUNTIME_DEPS + [":target_node", ":target_web"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) @@ -123,3 +124,20 @@ filegroup( ], visibility = ["//visibility:public"], ) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = TYPES_PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [ + ":npm_module_types", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-es-query/package.json b/packages/kbn-es-query/package.json index 335ef61b8b360..b317ce4ca4c95 100644 --- a/packages/kbn-es-query/package.json +++ b/packages/kbn-es-query/package.json @@ -2,7 +2,6 @@ "name": "@kbn/es-query", "browser": "./target_web/index.js", "main": "./target_node/index.js", - "types": "./target_types/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", "private": true diff --git a/packages/kbn-es-query/src/kuery/index.ts b/packages/kbn-es-query/src/kuery/index.ts index 868904125dc44..13039956916cb 100644 --- a/packages/kbn-es-query/src/kuery/index.ts +++ b/packages/kbn-es-query/src/kuery/index.ts @@ -23,4 +23,5 @@ export const toElasticsearchQuery = (...params: Parameters; diff --git a/packages/kbn-plugin-generator/BUILD.bazel b/packages/kbn-plugin-generator/BUILD.bazel index c935d1763dae8..488f09bdd5d52 100644 --- a/packages/kbn-plugin-generator/BUILD.bazel +++ b/packages/kbn-plugin-generator/BUILD.bazel @@ -51,7 +51,7 @@ RUNTIME_DEPS = [ TYPES_DEPS = [ "//packages/kbn-utils", - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "@npm//del", "@npm//execa", "@npm//globby", diff --git a/packages/kbn-plugin-helpers/BUILD.bazel b/packages/kbn-plugin-helpers/BUILD.bazel index d7744aecac26e..47f205f1530b7 100644 --- a/packages/kbn-plugin-helpers/BUILD.bazel +++ b/packages/kbn-plugin-helpers/BUILD.bazel @@ -42,7 +42,7 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "//packages/kbn-optimizer", "//packages/kbn-utils", "@npm//del", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index c1d0f69e4ea07..fc92d18698132 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -6639,7 +6639,15 @@ class ToolingLogTextWriter { } if (this.ignoreSources && msg.source && this.ignoreSources.includes(msg.source)) { - return false; + if (msg.type === 'write') { + const txt = (0, _util.format)(msg.args[0], ...msg.args.slice(1)); // Ensure that Elasticsearch deprecation log messages from Kibana aren't ignored + + if (!/elasticsearch\.deprecation/.test(txt)) { + return false; + } + } else { + return false; + } } const prefix = has(MSG_PREFIXES, msg.type) ? MSG_PREFIXES[msg.type] : ''; diff --git a/packages/kbn-rule-data-utils/BUILD.bazel b/packages/kbn-rule-data-utils/BUILD.bazel index 730e907aafc65..d23cf25f181ca 100644 --- a/packages/kbn-rule-data-utils/BUILD.bazel +++ b/packages/kbn-rule-data-utils/BUILD.bazel @@ -34,7 +34,7 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-es-query", + "//packages/kbn-es-query:npm_module_types", "@npm//@elastic/elasticsearch", "@npm//tslib", "@npm//utility-types", 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 349719c019c22..fde8deade36b5 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -24,6 +24,7 @@ const VERSION = `${KIBANA_NAMESPACE}.version` as const; // Fields pertaining to the alert const ALERT_ACTION_GROUP = `${ALERT_NAMESPACE}.action_group` as const; +const ALERT_BUILDING_BLOCK_TYPE = `${ALERT_NAMESPACE}.building_block_type` as const; const ALERT_DURATION = `${ALERT_NAMESPACE}.duration.us` as const; const ALERT_END = `${ALERT_NAMESPACE}.end` as const; const ALERT_EVALUATION_THRESHOLD = `${ALERT_NAMESPACE}.evaluation.threshold` as const; @@ -91,6 +92,7 @@ const fields = { TAGS, TIMESTAMP, ALERT_ACTION_GROUP, + ALERT_BUILDING_BLOCK_TYPE, ALERT_DURATION, ALERT_END, ALERT_EVALUATION_THRESHOLD, @@ -141,6 +143,7 @@ const fields = { export { ALERT_ACTION_GROUP, + ALERT_BUILDING_BLOCK_TYPE, ALERT_DURATION, ALERT_END, ALERT_EVALUATION_THRESHOLD, diff --git a/packages/kbn-securitysolution-autocomplete/BUILD.bazel b/packages/kbn-securitysolution-autocomplete/BUILD.bazel index 57ac8c62273e0..50df292b8796e 100644 --- a/packages/kbn-securitysolution-autocomplete/BUILD.bazel +++ b/packages/kbn-securitysolution-autocomplete/BUILD.bazel @@ -45,7 +45,7 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-es-query", + "//packages/kbn-es-query:npm_module_types", "//packages/kbn-i18n", "//packages/kbn-securitysolution-list-hooks", "//packages/kbn-securitysolution-list-utils", diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts index e491b50b0f9c8..176a6357b30e7 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts @@ -10,9 +10,11 @@ import { EndpointEntriesArray } from '.'; import { getEndpointEntryMatchMock } from '../entry_match/index.mock'; import { getEndpointEntryMatchAnyMock } from '../entry_match_any/index.mock'; import { getEndpointEntryNestedMock } from '../entry_nested/index.mock'; +import { getEndpointEntryMatchWildcard } from '../entry_match_wildcard/index.mock'; export const getEndpointEntriesArrayMock = (): EndpointEntriesArray => [ getEndpointEntryMatchMock(), getEndpointEntryMatchAnyMock(), getEndpointEntryNestedMock(), + getEndpointEntryMatchWildcard(), ]; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts index 09f1740567bc1..ca852e15c5c2a 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts @@ -20,6 +20,7 @@ import { getEndpointEntryNestedMock } from '../entry_nested/index.mock'; import { getEndpointEntriesArrayMock } from './index.mock'; import { getEntryListMock } from '../../entries_list/index.mock'; import { getEntryExistsMock } from '../../entries_exist/index.mock'; +import { getEndpointEntryMatchWildcard } from '../entry_match_wildcard/index.mock'; describe('Endpoint', () => { describe('entriesArray', () => { @@ -99,6 +100,15 @@ describe('Endpoint', () => { expect(message.schema).toEqual(payload); }); + test('it should validate an array with wildcard entry', () => { + const payload = [getEndpointEntryMatchWildcard()]; + const decoded = endpointEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + test('it should validate an array with all types of entries', () => { const payload = getEndpointEntriesArrayMock(); const decoded = endpointEntriesArray.decode(payload); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.ts index 451131dafc459..58b0d80f9c1fa 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.ts @@ -11,9 +11,15 @@ import { Either } from 'fp-ts/lib/Either'; import { endpointEntryMatch } from '../entry_match'; import { endpointEntryMatchAny } from '../entry_match_any'; import { endpointEntryNested } from '../entry_nested'; +import { endpointEntryMatchWildcard } from '../entry_match_wildcard'; export const endpointEntriesArray = t.array( - t.union([endpointEntryMatch, endpointEntryMatchAny, endpointEntryNested]) + t.union([ + endpointEntryMatch, + endpointEntryMatchAny, + endpointEntryMatchWildcard, + endpointEntryNested, + ]) ); export type EndpointEntriesArray = t.TypeOf; diff --git a/src/plugins/discover/public/utils/get_single_doc_url.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts similarity index 53% rename from src/plugins/discover/public/utils/get_single_doc_url.ts rename to packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts index 913463e6d44a4..e001552277e0c 100644 --- a/src/plugins/discover/public/utils/get_single_doc_url.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts @@ -6,6 +6,12 @@ * Side Public License, v 1. */ -export const getSingleDocUrl = (indexPatternId: string, rowIndex: string, rowId: string) => { - return `/app/discover#/doc/${indexPatternId}/${rowIndex}?id=${encodeURIComponent(rowId)}`; -}; +import { ENTRY_VALUE, FIELD, OPERATOR, WILDCARD } from '../../../constants/index.mock'; +import { EndpointEntryMatchWildcard } from './index'; + +export const getEndpointEntryMatchWildcard = (): EndpointEntryMatchWildcard => ({ + field: FIELD, + operator: OPERATOR, + type: WILDCARD, + value: ENTRY_VALUE, +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.mock.ts new file mode 100644 index 0000000000000..03ec225351e6d --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.mock.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 { ENTRIES } from '../../constants/index.mock'; +import { ImportExceptionListItemSchema, ImportExceptionListItemSchemaDecoded } from '.'; + +export const getImportExceptionsListItemSchemaMock = ( + itemId = 'item_id_1', + listId = 'detection_list_id' +): ImportExceptionListItemSchema => ({ + description: 'some description', + entries: ENTRIES, + item_id: itemId, + list_id: listId, + name: 'Query with a rule id', + type: 'simple', +}); + +export const getImportExceptionsListItemSchemaDecodedMock = ( + itemId = 'item_id_1', + listId = 'detection_list_id' +): ImportExceptionListItemSchemaDecoded => ({ + ...getImportExceptionsListItemSchemaMock(itemId, listId), + comments: [], + meta: undefined, + namespace_type: 'single', + os_types: [], + tags: [], +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.test.ts new file mode 100644 index 0000000000000..d202f65b57ab5 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright 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 { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; + +import { importExceptionListItemSchema, ImportExceptionListItemSchema } from '.'; +import { + getImportExceptionsListItemSchemaDecodedMock, + getImportExceptionsListItemSchemaMock, +} from './index.mock'; + +describe('import_list_item_schema', () => { + test('it should validate a typical item request', () => { + const payload = getImportExceptionsListItemSchemaMock(); + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getImportExceptionsListItemSchemaDecodedMock()); + }); + + test('it should NOT accept an undefined for "item_id"', () => { + const payload: Partial> = + getImportExceptionsListItemSchemaMock(); + delete payload.item_id; + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "item_id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "list_id"', () => { + const payload: Partial> = + getImportExceptionsListItemSchemaMock(); + delete payload.list_id; + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "list_id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "description"', () => { + const payload: Partial> = + getImportExceptionsListItemSchemaMock(); + delete payload.description; + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "description"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "name"', () => { + const payload: Partial> = + getImportExceptionsListItemSchemaMock(); + delete payload.name; + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "name"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "type"', () => { + const payload: Partial> = + getImportExceptionsListItemSchemaMock(); + delete payload.type; + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "entries"', () => { + const payload: Partial> = + getImportExceptionsListItemSchemaMock(); + delete payload.entries; + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should accept any partial fields', () => { + const payload: ImportExceptionListItemSchema = { + ...getImportExceptionsListItemSchemaMock(), + id: '123', + namespace_type: 'single', + comments: [], + os_types: [], + tags: ['123'], + created_at: '2018-08-24T17:49:30.145142000', + created_by: 'elastic', + updated_at: '2018-08-24T17:49:30.145142000', + updated_by: 'elastic', + tie_breaker_id: '123', + _version: '3', + meta: undefined, + }; + + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: ImportExceptionListItemSchema & { + extraKey?: string; + } = getImportExceptionsListItemSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts new file mode 100644 index 0000000000000..3da30a21a0115 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; + +import { OsTypeArray, osTypeArrayOrUndefined } from '../../common/os_type'; +import { Tags } from '../../common/tags'; +import { NamespaceType } from '../../common/default_namespace'; +import { name } from '../../common/name'; +import { description } from '../../common/description'; +import { namespace_type } from '../../common/namespace_type'; +import { tags } from '../../common/tags'; +import { meta } from '../../common/meta'; +import { list_id } from '../../common/list_id'; +import { item_id } from '../../common/item_id'; +import { id } from '../../common/id'; +import { created_at } from '../../common/created_at'; +import { created_by } from '../../common/created_by'; +import { updated_at } from '../../common/updated_at'; +import { updated_by } from '../../common/updated_by'; +import { _version } from '../../common/underscore_version'; +import { tie_breaker_id } from '../../common/tie_breaker_id'; +import { nonEmptyEntriesArray } from '../../common/non_empty_entries_array'; +import { exceptionListItemType } from '../../common/exception_list_item_type'; +import { ItemId } from '../../common/item_id'; +import { EntriesArray } from '../../common/entries'; +import { CreateCommentsArray } from '../../common/create_comment'; +import { DefaultCreateCommentsArray } from '../../common/default_create_comments_array'; + +/** + * Differences from this and the createExceptionsListItemSchema are + * - item_id is required + * - id is optional (but ignored in the import code - item_id is exclusively used for imports) + * - immutable is optional but if it is any value other than false it will be rejected + * - created_at is optional (but ignored in the import code) + * - updated_at is optional (but ignored in the import code) + * - created_by is optional (but ignored in the import code) + * - updated_by is optional (but ignored in the import code) + */ +export const importExceptionListItemSchema = t.intersection([ + t.exact( + t.type({ + description, + entries: nonEmptyEntriesArray, + item_id, + list_id, + name, + type: exceptionListItemType, + }) + ), + t.exact( + t.partial({ + id, // defaults to undefined if not set during decode + comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode + created_at, // defaults undefined if not set during decode + updated_at, // defaults undefined if not set during decode + created_by, // defaults undefined if not set during decode + updated_by, // defaults undefined if not set during decode + _version, // defaults to undefined if not set during decode + tie_breaker_id, + meta, // defaults to undefined if not set during decode + namespace_type, // defaults to 'single' if not set during decode + os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode + tags, // defaults to empty array if not set during decode + }) + ), +]); + +export type ImportExceptionListItemSchema = t.OutputOf; + +// This type is used after a decode since some things are defaults after a decode. +export type ImportExceptionListItemSchemaDecoded = Omit< + ImportExceptionListItemSchema, + 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' +> & { + comments: CreateCommentsArray; + tags: Tags; + item_id: ItemId; + entries: EntriesArray; + namespace_type: NamespaceType; + os_types: OsTypeArray; +}; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.mock.ts new file mode 100644 index 0000000000000..dc6aa8644c1f5 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.mock.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ImportExceptionListSchemaDecoded, ImportExceptionsListSchema } from '.'; + +export const getImportExceptionsListSchemaMock = ( + listId = 'detection_list_id' +): ImportExceptionsListSchema => ({ + description: 'some description', + list_id: listId, + name: 'Query with a rule id', + type: 'detection', +}); + +export const getImportExceptionsListSchemaDecodedMock = ( + listId = 'detection_list_id' +): ImportExceptionListSchemaDecoded => ({ + ...getImportExceptionsListSchemaMock(listId), + immutable: false, + meta: undefined, + namespace_type: 'single', + os_types: [], + tags: [], + version: 1, +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.test.ts new file mode 100644 index 0000000000000..92a24cd4352f5 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright 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 { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; + +import { importExceptionsListSchema, ImportExceptionsListSchema } from '.'; +import { + getImportExceptionsListSchemaMock, + getImportExceptionsListSchemaDecodedMock, +} from './index.mock'; + +describe('import_list_item_schema', () => { + test('it should validate a typical lists request', () => { + const payload = getImportExceptionsListSchemaMock(); + const decoded = importExceptionsListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getImportExceptionsListSchemaDecodedMock()); + }); + + test('it should NOT accept an undefined for "list_id"', () => { + const payload: Partial> = + getImportExceptionsListSchemaMock(); + delete payload.list_id; + const decoded = importExceptionsListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "list_id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "description"', () => { + const payload: Partial> = + getImportExceptionsListSchemaMock(); + delete payload.description; + const decoded = importExceptionsListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "description"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "name"', () => { + const payload: Partial> = + getImportExceptionsListSchemaMock(); + delete payload.name; + const decoded = importExceptionsListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "name"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "type"', () => { + const payload: Partial> = + getImportExceptionsListSchemaMock(); + delete payload.type; + const decoded = importExceptionsListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept value of "true" for "immutable"', () => { + const payload: ImportExceptionsListSchema = { + ...getImportExceptionsListSchemaMock(), + immutable: true, + }; + + const decoded = importExceptionsListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "true" supplied to "immutable"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should accept any partial fields', () => { + const payload: ImportExceptionsListSchema = { + ...getImportExceptionsListSchemaMock(), + namespace_type: 'single', + immutable: false, + os_types: [], + tags: ['123'], + created_at: '2018-08-24T17:49:30.145142000', + created_by: 'elastic', + updated_at: '2018-08-24T17:49:30.145142000', + updated_by: 'elastic', + version: 3, + tie_breaker_id: '123', + _version: '3', + meta: undefined, + }; + + const decoded = importExceptionsListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: ImportExceptionsListSchema & { + extraKey?: string; + } = getImportExceptionsListSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = importExceptionsListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.ts new file mode 100644 index 0000000000000..610bbae97f579 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_list_schema/index.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; + +import { + DefaultVersionNumber, + DefaultVersionNumberDecoded, + OnlyFalseAllowed, +} from '@kbn/securitysolution-io-ts-types'; + +import { exceptionListType } from '../../common/exception_list'; +import { OsTypeArray, osTypeArrayOrUndefined } from '../../common/os_type'; +import { Tags } from '../../common/tags'; +import { ListId } from '../../common/list_id'; +import { NamespaceType } from '../../common/default_namespace'; +import { name } from '../../common/name'; +import { description } from '../../common/description'; +import { namespace_type } from '../../common/namespace_type'; +import { tags } from '../../common/tags'; +import { meta } from '../../common/meta'; +import { list_id } from '../../common/list_id'; +import { id } from '../../common/id'; +import { created_at } from '../../common/created_at'; +import { created_by } from '../../common/created_by'; +import { updated_at } from '../../common/updated_at'; +import { updated_by } from '../../common/updated_by'; +import { _version } from '../../common/underscore_version'; +import { tie_breaker_id } from '../../common/tie_breaker_id'; + +/** + * Differences from this and the createExceptionsSchema are + * - list_id is required + * - id is optional (but ignored in the import code - list_id is exclusively used for imports) + * - immutable is optional but if it is any value other than false it will be rejected + * - created_at is optional (but ignored in the import code) + * - updated_at is optional (but ignored in the import code) + * - created_by is optional (but ignored in the import code) + * - updated_by is optional (but ignored in the import code) + */ +export const importExceptionsListSchema = t.intersection([ + t.exact( + t.type({ + description, + name, + type: exceptionListType, + list_id, + }) + ), + t.exact( + t.partial({ + id, // defaults to undefined if not set during decode + immutable: OnlyFalseAllowed, + meta, // defaults to undefined if not set during decode + namespace_type, // defaults to 'single' if not set during decode + os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode + tags, // defaults to empty array if not set during decode + created_at, // defaults "undefined" if not set during decode + updated_at, // defaults "undefined" if not set during decode + created_by, // defaults "undefined" if not set during decode + updated_by, // defaults "undefined" if not set during decode + _version, // defaults to undefined if not set during decode + tie_breaker_id, + version: DefaultVersionNumber, // defaults to numerical 1 if not set during decode + }) + ), +]); + +export type ImportExceptionsListSchema = t.TypeOf; + +// This type is used after a decode since some things are defaults after a decode. +export type ImportExceptionListSchemaDecoded = Omit< + ImportExceptionsListSchema, + 'tags' | 'list_id' | 'namespace_type' | 'os_types' | 'immutable' +> & { + immutable: false; + tags: Tags; + list_id: ListId; + namespace_type: NamespaceType; + os_types: OsTypeArray; + version: DefaultVersionNumberDecoded; +}; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/index.ts index 3d3c41aed5a72..da8bd7ed8306e 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/index.ts @@ -23,6 +23,8 @@ export * from './find_exception_list_item_schema'; export * from './find_list_item_schema'; export * from './find_list_schema'; export * from './import_list_item_query_schema'; +export * from './import_exception_list_schema'; +export * from './import_exception_item_schema'; export * from './import_list_item_schema'; export * from './patch_list_item_schema'; export * from './patch_list_schema'; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.mock.ts new file mode 100644 index 0000000000000..d4c17c7f9422e --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ImportExceptionsResponseSchema } from '.'; + +export const getImportExceptionsResponseSchemaMock = ( + success = 0, + lists = 0, + items = 0 +): ImportExceptionsResponseSchema => ({ + errors: [], + success: true, + success_count: success, + success_exception_lists: true, + success_count_exception_lists: lists, + success_exception_list_items: true, + success_count_exception_list_items: items, +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.test.ts new file mode 100644 index 0000000000000..dc6780d4b1ce2 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.test.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 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 { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; + +import { importExceptionsResponseSchema, ImportExceptionsResponseSchema } from '.'; +import { getImportExceptionsResponseSchemaMock } from './index.mock'; + +describe('importExceptionsResponseSchema', () => { + test('it should validate a typical exceptions import response', () => { + const payload = getImportExceptionsResponseSchemaMock(); + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT accept an undefined for "errors"', () => { + const payload: Partial> = + getImportExceptionsResponseSchemaMock(); + delete payload.errors; + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "errors"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "success"', () => { + const payload: Partial> = + getImportExceptionsResponseSchemaMock(); + delete payload.success; + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "success"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "success_count"', () => { + const payload: Partial> = + getImportExceptionsResponseSchemaMock(); + delete payload.success_count; + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "success_count"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "success_exception_lists"', () => { + const payload: Partial> = + getImportExceptionsResponseSchemaMock(); + delete payload.success_exception_lists; + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "success_exception_lists"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "success_count_exception_lists"', () => { + const payload: Partial> = + getImportExceptionsResponseSchemaMock(); + delete payload.success_count_exception_lists; + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "success_count_exception_lists"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "success_exception_list_items"', () => { + const payload: Partial> = + getImportExceptionsResponseSchemaMock(); + delete payload.success_exception_list_items; + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "success_exception_list_items"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "success_count_exception_list_items"', () => { + const payload: Partial> = + getImportExceptionsResponseSchemaMock(); + delete payload.success_count_exception_list_items; + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "success_count_exception_list_items"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: ImportExceptionsResponseSchema & { + extraKey?: string; + } = getImportExceptionsResponseSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = importExceptionsResponseSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.ts new file mode 100644 index 0000000000000..f50356d2789f8 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; + +import { PositiveInteger } from '@kbn/securitysolution-io-ts-types'; + +import { id } from '../../common/id'; +import { list_id } from '../../common/list_id'; +import { item_id } from '../../common/item_id'; + +export const bulkErrorErrorSchema = t.exact( + t.type({ + status_code: t.number, + message: t.string, + }) +); + +export const bulkErrorSchema = t.intersection([ + t.exact( + t.type({ + error: bulkErrorErrorSchema, + }) + ), + t.partial({ + id, + list_id, + item_id, + }), +]); + +export type BulkErrorSchema = t.TypeOf; + +export const importExceptionsResponseSchema = t.exact( + t.type({ + errors: t.array(bulkErrorSchema), + success: t.boolean, + success_count: PositiveInteger, + success_exception_lists: t.boolean, + success_count_exception_lists: PositiveInteger, + success_exception_list_items: t.boolean, + success_count_exception_list_items: PositiveInteger, + }) +); + +export type ImportExceptionsResponseSchema = t.TypeOf; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/response/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/response/index.ts index dc29bdf16ab48..c37b092eb3477 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/response/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/response/index.ts @@ -14,6 +14,7 @@ export * from './found_exception_list_item_schema'; export * from './found_exception_list_schema'; export * from './found_list_item_schema'; export * from './found_list_schema'; +export * from './import_exceptions_schema'; export * from './list_item_schema'; export * from './list_schema'; export * from './exception_list_summary_schema'; diff --git a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts new file mode 100644 index 0000000000000..03ec9df51a318 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { left } from 'fp-ts/lib/Either'; +import { ImportQuerySchema, importQuerySchema } from '.'; +import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; + +describe('importQuerySchema', () => { + test('it should validate proper schema', () => { + const payload = { + overwrite: true, + }; + const decoded = importQuerySchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate a non boolean value for "overwrite"', () => { + const payload: Omit & { overwrite: string } = { + overwrite: 'wrong', + }; + const decoded = importQuerySchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "wrong" supplied to "overwrite"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT allow an extra key to be sent in', () => { + const payload: ImportQuerySchema & { + extraKey?: string; + } = { + extraKey: 'extra', + overwrite: true, + }; + + const decoded = importQuerySchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts new file mode 100644 index 0000000000000..95cbf96b2ef8d --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.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 * as t from 'io-ts'; + +import { DefaultStringBooleanFalse } from '../default_string_boolean_false'; + +export const importQuerySchema = t.exact( + t.partial({ + overwrite: DefaultStringBooleanFalse, + }) +); + +export type ImportQuerySchema = t.TypeOf; +export type ImportQuerySchemaDecoded = Omit & { + overwrite: boolean; +}; diff --git a/packages/kbn-securitysolution-io-ts-types/src/index.ts b/packages/kbn-securitysolution-io-ts-types/src/index.ts index b85bff63fe2a7..0bb99e4c766e7 100644 --- a/packages/kbn-securitysolution-io-ts-types/src/index.ts +++ b/packages/kbn-securitysolution-io-ts-types/src/index.ts @@ -17,6 +17,7 @@ export * from './default_version_number'; export * from './empty_string_array'; export * from './enumeration'; export * from './iso_date_string'; +export * from './import_query_schema'; export * from './non_empty_array'; export * from './non_empty_or_nullable_string_array'; export * from './non_empty_string_array'; diff --git a/packages/kbn-securitysolution-list-utils/BUILD.bazel b/packages/kbn-securitysolution-list-utils/BUILD.bazel index eb33eb1a03b66..30568ca725041 100644 --- a/packages/kbn-securitysolution-list-utils/BUILD.bazel +++ b/packages/kbn-securitysolution-list-utils/BUILD.bazel @@ -38,11 +38,12 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-es-query", - "//packages/kbn-i18n", + "//packages/kbn-es-query:npm_module_types", + "//packages/kbn-i18n:npm_module_types", "//packages/kbn-securitysolution-io-ts-list-types", "//packages/kbn-securitysolution-list-constants", "//packages/kbn-securitysolution-utils", + "@npm//@elastic/elasticsearch", "@npm//@types/jest", "@npm//@types/lodash", "@npm//@types/node", diff --git a/packages/kbn-storybook/BUILD.bazel b/packages/kbn-storybook/BUILD.bazel index f2a7bf25fb407..5dbe22b56c63f 100644 --- a/packages/kbn-storybook/BUILD.bazel +++ b/packages/kbn-storybook/BUILD.bazel @@ -32,6 +32,7 @@ RUNTIME_DEPS = [ "//packages/kbn-dev-utils", "//packages/kbn-ui-shared-deps-npm", "//packages/kbn-ui-shared-deps-src", + "//packages/kbn-utils", "@npm//@storybook/addons", "@npm//@storybook/api", "@npm//@storybook/components", @@ -47,9 +48,10 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "//packages/kbn-ui-shared-deps-npm", "//packages/kbn-ui-shared-deps-src", + "//packages/kbn-utils", "@npm//@storybook/addons", "@npm//@storybook/api", "@npm//@storybook/components", diff --git a/packages/kbn-storybook/src/lib/constants.ts b/packages/kbn-storybook/src/lib/constants.ts index 722f789fde786..69b05c94ea1b0 100644 --- a/packages/kbn-storybook/src/lib/constants.ts +++ b/packages/kbn-storybook/src/lib/constants.ts @@ -7,7 +7,7 @@ */ import { resolve } from 'path'; -import { REPO_ROOT as KIBANA_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT as KIBANA_ROOT } from '@kbn/utils'; export const REPO_ROOT = KIBANA_ROOT; export const ASSET_DIR = resolve(KIBANA_ROOT, 'built_assets/storybook'); diff --git a/packages/kbn-telemetry-tools/BUILD.bazel b/packages/kbn-telemetry-tools/BUILD.bazel index 1183de2586424..d2ea3a704f154 100644 --- a/packages/kbn-telemetry-tools/BUILD.bazel +++ b/packages/kbn-telemetry-tools/BUILD.bazel @@ -38,8 +38,9 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "//packages/kbn-utility-types", + "@npm//tslib", "@npm//@types/glob", "@npm//@types/jest", "@npm//@types/listr", diff --git a/packages/kbn-test/BUILD.bazel b/packages/kbn-test/BUILD.bazel index 1d1d95d639861..eae0fe2cdf5dc 100644 --- a/packages/kbn-test/BUILD.bazel +++ b/packages/kbn-test/BUILD.bazel @@ -44,11 +44,13 @@ RUNTIME_DEPS = [ "@npm//axios", "@npm//@babel/traverse", "@npm//chance", + "@npm//dedent", "@npm//del", "@npm//enzyme", "@npm//execa", "@npm//exit-hook", "@npm//form-data", + "@npm//getopts", "@npm//globby", "@npm//he", "@npm//history", @@ -59,6 +61,7 @@ RUNTIME_DEPS = [ "@npm//@jest/reporters", "@npm//joi", "@npm//mustache", + "@npm//normalize-path", "@npm//parse-link-header", "@npm//prettier", "@npm//react-dom", @@ -72,13 +75,17 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/kbn-dev-utils", + "//packages/kbn-dev-utils:npm_module_types", "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-std", "//packages/kbn-utils", "@npm//@elastic/elasticsearch", + "@npm//axios", "@npm//elastic-apm-node", "@npm//del", + "@npm//exit-hook", "@npm//form-data", + "@npm//getopts", "@npm//jest", "@npm//jest-cli", "@npm//jest-snapshot", @@ -86,6 +93,7 @@ TYPES_DEPS = [ "@npm//rxjs", "@npm//xmlbuilder", "@npm//@types/chance", + "@npm//@types/dedent", "@npm//@types/enzyme", "@npm//@types/he", "@npm//@types/history", @@ -93,6 +101,7 @@ TYPES_DEPS = [ "@npm//@types/joi", "@npm//@types/lodash", "@npm//@types/mustache", + "@npm//@types/normalize-path", "@npm//@types/node", "@npm//@types/parse-link-header", "@npm//@types/prettier", diff --git a/packages/kbn-test/src/es/es_test_config.ts b/packages/kbn-test/src/es/es_test_config.ts index db5d705710a75..70000c8068e9f 100644 --- a/packages/kbn-test/src/es/es_test_config.ts +++ b/packages/kbn-test/src/es/es_test_config.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { kibanaPackageJson as pkg } from '@kbn/dev-utils'; +import { kibanaPackageJson as pkg } from '@kbn/utils'; import Url from 'url'; import { adminTestUser } from '../kbn'; diff --git a/packages/kbn-test/src/failed_tests_reporter/buildkite_metadata.ts b/packages/kbn-test/src/failed_tests_reporter/buildkite_metadata.ts new file mode 100644 index 0000000000000..d63f0166390cb --- /dev/null +++ b/packages/kbn-test/src/failed_tests_reporter/buildkite_metadata.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 interface BuildkiteMetadata { + buildId?: string; + jobId?: string; + url?: string; + jobName?: string; + jobUrl?: string; +} + +export function getBuildkiteMetadata(): BuildkiteMetadata { + // Buildkite steps that use `parallelism` need a numerical suffix added to identify them + // We should also increment the number by one, since it's 0-based + const jobNumberSuffix = process.env.BUILDKITE_PARALLEL_JOB + ? ` #${parseInt(process.env.BUILDKITE_PARALLEL_JOB, 10) + 1}` + : ''; + + const buildUrl = process.env.BUILDKITE_BUILD_URL; + const jobUrl = process.env.BUILDKITE_JOB_ID + ? `${buildUrl}#${process.env.BUILDKITE_JOB_ID}` + : undefined; + + return { + buildId: process.env.BUJILDKITE_BUILD_ID, + jobId: process.env.BUILDKITE_JOB_ID, + url: buildUrl, + jobUrl, + jobName: process.env.BUILDKITE_LABEL + ? `${process.env.BUILDKITE_LABEL}${jobNumberSuffix}` + : undefined, + }; +} diff --git a/packages/kbn-test/src/failed_tests_reporter/github_api.ts b/packages/kbn-test/src/failed_tests_reporter/github_api.ts index adaae11b7aa16..bb7570225a013 100644 --- a/packages/kbn-test/src/failed_tests_reporter/github_api.ts +++ b/packages/kbn-test/src/failed_tests_reporter/github_api.ts @@ -42,6 +42,7 @@ export class GithubApi { private readonly token: string | undefined; private readonly dryRun: boolean; private readonly x: AxiosInstance; + private requestCount: number = 0; /** * Create a GithubApi helper object, if token is undefined requests won't be @@ -68,6 +69,10 @@ export class GithubApi { }); } + getRequestCount() { + return this.requestCount; + } + private failedTestIssuesPageCache: { pages: GithubIssue[][]; nextRequest: RequestOptions | undefined; @@ -191,53 +196,50 @@ export class GithubApi { }> { const executeRequest = !this.dryRun || options.safeForDryRun; const maxAttempts = options.maxAttempts || 5; - const attempt = options.attempt || 1; - - this.log.verbose('Github API', executeRequest ? 'Request' : 'Dry Run', options); - - if (!executeRequest) { - return { - status: 200, - statusText: 'OK', - headers: {}, - data: dryRunResponse, - }; - } - try { - return await this.x.request(options); - } catch (error) { - const unableToReachGithub = isAxiosRequestError(error); - const githubApiFailed = isAxiosResponseError(error) && error.response.status >= 500; - const errorResponseLog = - isAxiosResponseError(error) && - `[${error.config.method} ${error.config.url}] ${error.response.status} ${error.response.statusText} Error`; + let attempt = 0; + while (true) { + attempt += 1; + this.log.verbose('Github API', executeRequest ? 'Request' : 'Dry Run', options); + + if (!executeRequest) { + return { + status: 200, + statusText: 'OK', + headers: {}, + data: dryRunResponse, + }; + } - if ((unableToReachGithub || githubApiFailed) && attempt < maxAttempts) { - const waitMs = 1000 * attempt; + try { + this.requestCount += 1; + return await this.x.request(options); + } catch (error) { + const unableToReachGithub = isAxiosRequestError(error); + const githubApiFailed = isAxiosResponseError(error) && error.response.status >= 500; + const errorResponseLog = + isAxiosResponseError(error) && + `[${error.config.method} ${error.config.url}] ${error.response.status} ${error.response.statusText} Error`; + + if ((unableToReachGithub || githubApiFailed) && attempt < maxAttempts) { + const waitMs = 1000 * attempt; + + if (errorResponseLog) { + this.log.error(`${errorResponseLog}: waiting ${waitMs}ms to retry`); + } else { + this.log.error(`Unable to reach github, waiting ${waitMs}ms to retry`); + } + + await new Promise((resolve) => setTimeout(resolve, waitMs)); + continue; + } if (errorResponseLog) { - this.log.error(`${errorResponseLog}: waiting ${waitMs}ms to retry`); - } else { - this.log.error(`Unable to reach github, waiting ${waitMs}ms to retry`); + throw new Error(`${errorResponseLog}: ${JSON.stringify(error.response.data)}`); } - await new Promise((resolve) => setTimeout(resolve, waitMs)); - return await this.request( - { - ...options, - maxAttempts, - attempt: attempt + 1, - }, - dryRunResponse - ); + throw error; } - - if (errorResponseLog) { - throw new Error(`${errorResponseLog}: ${JSON.stringify(error.response.data)}`); - } - - throw error; } } } diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file.ts b/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file.ts index e481da019945c..33dab240ec8b4 100644 --- a/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file.ts +++ b/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file.ts @@ -14,6 +14,7 @@ import { ToolingLog } from '@kbn/dev-utils'; import { REPO_ROOT } from '@kbn/utils'; import { escape } from 'he'; +import { BuildkiteMetadata } from './buildkite_metadata'; import { TestFailure } from './get_failures'; const findScreenshots = (dirPath: string, allScreenshots: string[] = []) => { @@ -37,7 +38,11 @@ const findScreenshots = (dirPath: string, allScreenshots: string[] = []) => { return allScreenshots; }; -export function reportFailuresToFile(log: ToolingLog, failures: TestFailure[]) { +export function reportFailuresToFile( + log: ToolingLog, + failures: TestFailure[], + bkMeta: BuildkiteMetadata +) { if (!failures?.length) { return; } @@ -76,28 +81,15 @@ export function reportFailuresToFile(log: ToolingLog, failures: TestFailure[]) { .flat() .join('\n'); - // Buildkite steps that use `parallelism` need a numerical suffix added to identify them - // We should also increment the number by one, since it's 0-based - const jobNumberSuffix = process.env.BUILDKITE_PARALLEL_JOB - ? ` #${parseInt(process.env.BUILDKITE_PARALLEL_JOB, 10) + 1}` - : ''; - - const buildUrl = process.env.BUILDKITE_BUILD_URL || ''; - const jobUrl = process.env.BUILDKITE_JOB_ID - ? `${buildUrl}#${process.env.BUILDKITE_JOB_ID}` - : ''; - const failureJSON = JSON.stringify( { ...failure, hash, - buildId: process.env.BUJILDKITE_BUILD_ID || '', - jobId: process.env.BUILDKITE_JOB_ID || '', - url: buildUrl, - jobUrl, - jobName: process.env.BUILDKITE_LABEL - ? `${process.env.BUILDKITE_LABEL}${jobNumberSuffix}` - : '', + buildId: bkMeta.buildId, + jobId: bkMeta.jobId, + url: bkMeta.url, + jobUrl: bkMeta.jobUrl, + jobName: bkMeta.jobName, }, null, 2 @@ -149,11 +141,11 @@ export function reportFailuresToFile(log: ToolingLog, failures: TestFailure[]) {

${ - jobUrl + bkMeta.jobUrl ? `

Buildkite Job
- ${escape(jobUrl)} + ${escape(bkMeta.jobUrl)}

` : '' diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index 193bc668ce003..6ab135a6afa7e 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -9,7 +9,7 @@ import Path from 'path'; import { REPO_ROOT } from '@kbn/utils'; -import { run, createFailError, createFlagError } from '@kbn/dev-utils'; +import { run, createFailError, createFlagError, CiStatsReporter } from '@kbn/dev-utils'; import globby from 'globby'; import normalize from 'normalize-path'; @@ -22,6 +22,7 @@ import { addMessagesToReport } from './add_messages_to_report'; import { getReportMessageIter } from './report_metadata'; import { reportFailuresToEs } from './report_failures_to_es'; import { reportFailuresToFile } from './report_failures_to_file'; +import { getBuildkiteMetadata } from './buildkite_metadata'; const DEFAULT_PATTERNS = [Path.resolve(REPO_ROOT, 'target/junit/**/*.xml')]; @@ -71,108 +72,129 @@ export function runFailedTestsReporterCli() { dryRun: !updateGithub, }); - const buildUrl = flags['build-url'] || (updateGithub ? '' : 'http://buildUrl'); - if (typeof buildUrl !== 'string' || !buildUrl) { - throw createFlagError('Missing --build-url or process.env.BUILD_URL'); - } + const bkMeta = getBuildkiteMetadata(); - const patterns = (flags._.length ? flags._ : DEFAULT_PATTERNS).map((p) => - normalize(Path.resolve(p)) - ); - log.info('Searching for reports at', patterns); - const reportPaths = await globby(patterns, { - absolute: true, - }); + try { + const buildUrl = flags['build-url'] || (updateGithub ? '' : 'http://buildUrl'); + if (typeof buildUrl !== 'string' || !buildUrl) { + throw createFlagError('Missing --build-url or process.env.BUILD_URL'); + } - if (!reportPaths.length) { - throw createFailError(`Unable to find any junit reports with patterns [${patterns}]`); - } + const patterns = (flags._.length ? flags._ : DEFAULT_PATTERNS).map((p) => + normalize(Path.resolve(p)) + ); + log.info('Searching for reports at', patterns); + const reportPaths = await globby(patterns, { + absolute: true, + }); - log.info('found', reportPaths.length, 'junit reports', reportPaths); - const newlyCreatedIssues: Array<{ - failure: TestFailure; - newIssue: GithubIssueMini; - }> = []; + if (!reportPaths.length) { + throw createFailError(`Unable to find any junit reports with patterns [${patterns}]`); + } - for (const reportPath of reportPaths) { - const report = await readTestReport(reportPath); - const messages = Array.from(getReportMessageIter(report)); - const failures = await getFailures(report); + log.info('found', reportPaths.length, 'junit reports', reportPaths); + const newlyCreatedIssues: Array<{ + failure: TestFailure; + newIssue: GithubIssueMini; + }> = []; - if (indexInEs) { - await reportFailuresToEs(log, failures); - } + for (const reportPath of reportPaths) { + const report = await readTestReport(reportPath); + const messages = Array.from(getReportMessageIter(report)); + const failures = await getFailures(report); - for (const failure of failures) { - const pushMessage = (msg: string) => { - messages.push({ - classname: failure.classname, - name: failure.name, - message: msg, - }); - }; - - if (failure.likelyIrrelevant) { - pushMessage( - 'Failure is likely irrelevant' + - (updateGithub ? ', so an issue was not created or updated' : '') - ); - continue; + if (indexInEs) { + await reportFailuresToEs(log, failures); } - let existingIssue: GithubIssueMini | undefined = await githubApi.findFailedTestIssue( - (i) => - getIssueMetadata(i.body, 'test.class') === failure.classname && - getIssueMetadata(i.body, 'test.name') === failure.name - ); + for (const failure of failures) { + const pushMessage = (msg: string) => { + messages.push({ + classname: failure.classname, + name: failure.name, + message: msg, + }); + }; + + if (failure.likelyIrrelevant) { + pushMessage( + 'Failure is likely irrelevant' + + (updateGithub ? ', so an issue was not created or updated' : '') + ); + continue; + } - if (!existingIssue) { - const newlyCreated = newlyCreatedIssues.find( - ({ failure: f }) => f.classname === failure.classname && f.name === failure.name - ); + let existingIssue: GithubIssueMini | undefined = updateGithub + ? await githubApi.findFailedTestIssue( + (i) => + getIssueMetadata(i.body, 'test.class') === failure.classname && + getIssueMetadata(i.body, 'test.name') === failure.name + ) + : undefined; + + if (!existingIssue) { + const newlyCreated = newlyCreatedIssues.find( + ({ failure: f }) => f.classname === failure.classname && f.name === failure.name + ); + + if (newlyCreated) { + existingIssue = newlyCreated.newIssue; + } + } - if (newlyCreated) { - existingIssue = newlyCreated.newIssue; + if (existingIssue) { + const newFailureCount = await updateFailureIssue( + buildUrl, + existingIssue, + githubApi, + branch + ); + const url = existingIssue.html_url; + failure.githubIssue = url; + failure.failureCount = updateGithub ? newFailureCount : newFailureCount - 1; + pushMessage( + `Test has failed ${newFailureCount - 1} times on tracked branches: ${url}` + ); + if (updateGithub) { + pushMessage(`Updated existing issue: ${url} (fail count: ${newFailureCount})`); + } + continue; } - } - if (existingIssue) { - const newFailureCount = await updateFailureIssue( - buildUrl, - existingIssue, - githubApi, - branch - ); - const url = existingIssue.html_url; - failure.githubIssue = url; - failure.failureCount = updateGithub ? newFailureCount : newFailureCount - 1; - pushMessage(`Test has failed ${newFailureCount - 1} times on tracked branches: ${url}`); + const newIssue = await createFailureIssue(buildUrl, failure, githubApi, branch); + pushMessage('Test has not failed recently on tracked branches'); if (updateGithub) { - pushMessage(`Updated existing issue: ${url} (fail count: ${newFailureCount})`); + pushMessage(`Created new issue: ${newIssue.html_url}`); + failure.githubIssue = newIssue.html_url; } - continue; - } - - const newIssue = await createFailureIssue(buildUrl, failure, githubApi, branch); - pushMessage('Test has not failed recently on tracked branches'); - if (updateGithub) { - pushMessage(`Created new issue: ${newIssue.html_url}`); - failure.githubIssue = newIssue.html_url; + newlyCreatedIssues.push({ failure, newIssue }); + failure.failureCount = updateGithub ? 1 : 0; } - newlyCreatedIssues.push({ failure, newIssue }); - failure.failureCount = updateGithub ? 1 : 0; - } - // mutates report to include messages and writes updated report to disk - await addMessagesToReport({ - report, - messages, - log, - reportPath, - dryRun: !flags['report-update'], - }); + // mutates report to include messages and writes updated report to disk + await addMessagesToReport({ + report, + messages, + log, + reportPath, + dryRun: !flags['report-update'], + }); - reportFailuresToFile(log, failures); + reportFailuresToFile(log, failures, bkMeta); + } + } finally { + await CiStatsReporter.fromEnv(log).metrics([ + { + group: 'github api request count', + id: `failed test reporter`, + value: githubApi.getRequestCount(), + meta: Object.fromEntries( + Object.entries(bkMeta).map( + ([k, v]) => [`buildkite${k[0].toUpperCase()}${k.slice(1)}`, v] as const + ) + ), + }, + ]); } }, { diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/validate_ci_group_tags.js b/packages/kbn-test/src/functional_test_runner/lib/mocha/validate_ci_group_tags.js index 3446c5be5d4a7..4f798839d7231 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/validate_ci_group_tags.js +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/validate_ci_group_tags.js @@ -8,7 +8,7 @@ import Path from 'path'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; /** * Traverse the suites configured and ensure that each suite has no more than one ciGroup assigned diff --git a/packages/kbn-test/src/functional_test_runner/lib/suite_tracker.test.ts b/packages/kbn-test/src/functional_test_runner/lib/suite_tracker.test.ts index e87f316a100a7..53ce4c74c1388 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/suite_tracker.test.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/suite_tracker.test.ts @@ -14,7 +14,7 @@ jest.mock('@kbn/utils', () => { return { REPO_ROOT: '/dev/null/root' }; }); -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Lifecycle } from './lifecycle'; import { SuiteTracker } from './suite_tracker'; import { Suite } from '../fake_mocha_types'; diff --git a/packages/kbn-test/src/functional_tests/lib/babel_register_for_test_plugins.js b/packages/kbn-test/src/functional_tests/lib/babel_register_for_test_plugins.js index 03947f7e267ba..63d2b56350ba1 100644 --- a/packages/kbn-test/src/functional_tests/lib/babel_register_for_test_plugins.js +++ b/packages/kbn-test/src/functional_tests/lib/babel_register_for_test_plugins.js @@ -9,7 +9,7 @@ const Fs = require('fs'); const Path = require('path'); -const { REPO_ROOT: REPO_ROOT_FOLLOWING_SYMLINKS } = require('@kbn/dev-utils'); +const { REPO_ROOT: REPO_ROOT_FOLLOWING_SYMLINKS } = require('@kbn/utils'); const BASE_REPO_ROOT = Path.resolve( Fs.realpathSync(Path.resolve(REPO_ROOT_FOLLOWING_SYMLINKS, 'package.json')), '..' diff --git a/packages/kbn-test/src/functional_tests/tasks.ts b/packages/kbn-test/src/functional_tests/tasks.ts index 6dde114d3a98e..6a6c7edb98c79 100644 --- a/packages/kbn-test/src/functional_tests/tasks.ts +++ b/packages/kbn-test/src/functional_tests/tasks.ts @@ -9,7 +9,8 @@ import { relative } from 'path'; import * as Rx from 'rxjs'; import { startWith, switchMap, take } from 'rxjs/operators'; -import { withProcRunner, ToolingLog, REPO_ROOT, getTimeReporter } from '@kbn/dev-utils'; +import { withProcRunner, ToolingLog, getTimeReporter } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import dedent from 'dedent'; import { diff --git a/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx b/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx index ee5e06ebf6947..696a1d1b63163 100644 --- a/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx +++ b/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx @@ -46,7 +46,11 @@ function getOptions(context = {}, childContextTypes = {}, props = {}) { /** * When using @kbn/i18n `injectI18n` on components, props.intl is required. */ -function nodeWithIntlProp(node: ReactElement): ReactElement { +// This function is exported solely to fix the types output in TS 4.5.2, likely a bug +// Otherwise, InjectedIntl is missing from the output +export function nodeWithIntlProp( + node: ReactElement +): ReactElement { return React.cloneElement(node, { intl }); } diff --git a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts index 4adae7d1cd031..6da34228bbe7f 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts @@ -12,7 +12,8 @@ import { existsSync } from 'fs'; import Path from 'path'; import FormData from 'form-data'; -import { ToolingLog, isAxiosResponseError, createFailError, REPO_ROOT } from '@kbn/dev-utils'; +import { ToolingLog, isAxiosResponseError, createFailError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { KbnClientRequester, uriencode, ReqOptions } from './kbn_client_requester'; import { KbnClientSavedObjects } from './kbn_client_saved_objects'; 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 c1ae5afd816ee..f15fd99a02a87 100644 --- a/packages/kbn-typed-react-router-config/src/types/index.ts +++ b/packages/kbn-typed-react-router-config/src/types/index.ts @@ -13,97 +13,13 @@ import { RequiredKeys, ValuesType } from 'utility-types'; // import { unconst } from '../unconst'; import { NormalizePath } from './utils'; -type PathsOfRoute = - | TRoute['path'] - | (TRoute extends { children: Route[] } - ? AppendPath | PathsOf - : never); - -export type PathsOf = TRoutes extends [] - ? never - : TRoutes extends [Route] - ? PathsOfRoute - : TRoutes extends [Route, Route] - ? PathsOfRoute | PathsOfRoute - : TRoutes extends [Route, Route, Route] - ? PathsOfRoute | PathsOfRoute | PathsOfRoute - : TRoutes extends [Route, Route, Route, Route] - ? - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - : TRoutes extends [Route, Route, Route, Route, Route] - ? - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - : TRoutes extends [Route, Route, Route, Route, Route, Route] - ? - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - : TRoutes extends [Route, Route, Route, Route, Route, Route, Route] - ? - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - : TRoutes extends [Route, Route, Route, Route, Route, Route, Route, Route] - ? - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - : TRoutes extends [Route, Route, Route, Route, Route, Route, Route, Route, Route] - ? - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - : TRoutes extends [Route, Route, Route, Route, Route, Route, Route, Route, Route, Route] - ? - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - : TRoutes extends [Route, Route, Route, Route, Route, Route, Route, Route, Route, Route, Route] - ? - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - | PathsOfRoute - : string; +// type PathsOfRoute = +// | TRoute['path'] +// | (TRoute extends { children: Route[] } +// ? AppendPath | PathsOf +// : never); + +export type PathsOf = keyof MapRoutes & string; export interface RouteMatch { route: TRoute; @@ -347,6 +263,14 @@ type MapRoutes = TRoutes extends [Route] // const routes = unconst([ // { +// path: '/link-to/transaction/{transactionId}', +// element, +// }, +// { +// path: '/link-to/trace/{traceId}', +// element, +// }, +// { // path: '/', // element, // children: [ @@ -393,6 +317,10 @@ type MapRoutes = TRoutes extends [Route] // element, // }, // { +// path: '/settings/agent-keys', +// element, +// }, +// { // path: '/settings', // element, // }, @@ -430,11 +358,19 @@ type MapRoutes = TRoutes extends [Route] // element, // }, // { +// path: '/services/:serviceName/transactions/view', +// element, +// }, +// { +// path: '/services/:serviceName/dependencies', +// element, +// }, +// { // path: '/services/:serviceName/errors', // element, // children: [ // { -// path: '/:groupId', +// path: '/services/:serviceName/errors/:groupId', // element, // params: t.type({ // path: t.type({ @@ -443,7 +379,7 @@ type MapRoutes = TRoutes extends [Route] // }), // }, // { -// path: '/services/:serviceName', +// path: '/services/:serviceName/errors', // element, // params: t.partial({ // query: t.partial({ @@ -457,15 +393,33 @@ type MapRoutes = TRoutes extends [Route] // ], // }, // { -// path: '/services/:serviceName/foo', +// path: '/services/:serviceName/metrics', +// element, +// }, +// { +// path: '/services/:serviceName/nodes', +// element, +// children: [ +// { +// path: '/services/{serviceName}/nodes/{serviceNodeName}/metrics', +// element, +// }, +// { +// path: '/services/:serviceName/nodes', +// element, +// }, +// ], +// }, +// { +// path: '/services/:serviceName/service-map', // element, // }, // { -// path: '/services/:serviceName/bar', +// path: '/services/:serviceName/logs', // element, // }, // { -// path: '/services/:serviceName/baz', +// path: '/services/:serviceName/profiling', // element, // }, // { @@ -497,6 +451,24 @@ type MapRoutes = TRoutes extends [Route] // element, // }, // { +// path: '/backends', +// element, +// children: [ +// { +// path: '/backends/{backendName}/overview', +// element, +// }, +// { +// path: '/backends/overview', +// element, +// }, +// { +// path: '/backends', +// element, +// }, +// ], +// }, +// { // path: '/', // element, // }, @@ -509,10 +481,11 @@ type MapRoutes = TRoutes extends [Route] // type Routes = typeof routes; // type Mapped = keyof MapRoutes; +// type Paths = PathsOf; // type Bar = ValuesType>['route']['path']; // type Foo = OutputOf; -// type Baz = OutputOf; +// // type Baz = OutputOf; // const { path }: Foo = {} as any; @@ -520,4 +493,4 @@ type MapRoutes = TRoutes extends [Route] // return {} as any; // } -// const params = _useApmParams('/*'); +// // const params = _useApmParams('/services/:serviceName/nodes/*'); diff --git a/src/cli/serve/integration_tests/invalid_config.test.ts b/src/cli/serve/integration_tests/invalid_config.test.ts index 2de902582a548..ca051f37a816e 100644 --- a/src/cli/serve/integration_tests/invalid_config.test.ts +++ b/src/cli/serve/integration_tests/invalid_config.test.ts @@ -8,7 +8,7 @@ import { spawnSync } from 'child_process'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; const INVALID_CONFIG_PATH = require.resolve('./__fixtures__/invalid_config.yml'); diff --git a/src/core/public/apm_system.test.ts b/src/core/public/apm_system.test.ts index f62421cb55abc..842d5de7e5afc 100644 --- a/src/core/public/apm_system.test.ts +++ b/src/core/public/apm_system.test.ts @@ -9,6 +9,7 @@ jest.mock('@elastic/apm-rum'); import type { DeeplyMockedKeys, MockedKeys } from '@kbn/utility-types/jest'; import { init, apm } from '@elastic/apm-rum'; +import type { Transaction } from '@elastic/apm-rum'; import { ApmSystem } from './apm_system'; import { Subject } from 'rxjs'; import { InternalApplicationStart } from './application/types'; diff --git a/src/core/public/apm_system.ts b/src/core/public/apm_system.ts index f15a317f9f934..2231f394381f0 100644 --- a/src/core/public/apm_system.ts +++ b/src/core/public/apm_system.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { ApmBase, AgentConfigOptions } from '@elastic/apm-rum'; +import type { ApmBase, AgentConfigOptions, Transaction } from '@elastic/apm-rum'; import { modifyUrl } from '@kbn/std'; import { CachedResourceObserver } from './apm_resource_counter'; import type { InternalApplicationStart } from './application'; diff --git a/src/core/public/chrome/recently_accessed/recently_accessed_service.test.ts b/src/core/public/chrome/recently_accessed/recently_accessed_service.test.ts index 10b4f510d4afa..bcf58b7a40ab1 100644 --- a/src/core/public/chrome/recently_accessed/recently_accessed_service.test.ts +++ b/src/core/public/chrome/recently_accessed/recently_accessed_service.test.ts @@ -44,11 +44,9 @@ describe('RecentlyAccessed#start()', () => { let originalLocalStorage: Storage; beforeAll(() => { originalLocalStorage = window.localStorage; - // @ts-expect-error window.localStorage = new LocalStorageMock(); }); beforeEach(() => localStorage.clear()); - // @ts-expect-error afterAll(() => (window.localStorage = originalLocalStorage)); const getStart = async () => { diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index e93ef34c38025..1c394112a404c 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -98,6 +98,7 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiDataGridToolbar.fullScreenButtonActive": "Exit full screen", "euiDatePopoverButton.invalidTitle": [Function], "euiDatePopoverButton.outdatedTitle": [Function], + "euiErrorBoundary.error": "Error", "euiFieldPassword.maskPassword": "Mask password", "euiFieldPassword.showPassword": "Show password as plain text. Note: this will visually expose your password on the screen.", "euiFilePicker.clearSelectedFiles": "Clear selected files", @@ -218,7 +219,7 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiStyleSelector.labelExpanded": "Expanded density", "euiStyleSelector.labelNormal": "Normal density", "euiSuperDatePicker.showDatesButtonLabel": "Show dates", - "euiSuperSelect.screenReaderAnnouncement": [Function], + "euiSuperSelect.screenReaderAnnouncement": "You are in a form selector and must select a single option. Use the up and down keys to navigate or escape to close.", "euiSuperSelectControl.selectAnOption": [Function], "euiSuperUpdateButton.cannotUpdateTooltip": "Cannot update", "euiSuperUpdateButton.clickToApplyTooltip": "Click to apply", diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 7c4d39fa2b11a..e3357d138e794 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -663,6 +663,10 @@ export const getEuiContextMapping = (): EuiTokensObject => { defaultMessage: '+ {messagesLength} more', values: { messagesLength }, }), + 'euiErrorBoundary.error': i18n.translate('core.euiErrorBoundary.error', { + defaultMessage: 'Error', + description: 'Error boundary for uncaught exceptions when rendering part of the application', + }), 'euiNotificationEventMessages.accordionAriaLabelButtonText': ({ messagesLength, eventName, @@ -1046,12 +1050,13 @@ export const getEuiContextMapping = (): EuiTokensObject => { description: 'Displayed in a button that shows date picker', } ), - 'euiSuperSelect.screenReaderAnnouncement': ({ optionsCount }: EuiValues) => - i18n.translate('core.euiSuperSelect.screenReaderAnnouncement', { + 'euiSuperSelect.screenReaderAnnouncement': i18n.translate( + 'core.euiSuperSelect.screenReaderAnnouncement', + { defaultMessage: - 'You are in a form selector of {optionsCount} items and must select a single option. Use the up and down keys to navigate or escape to close.', - values: { optionsCount }, - }), + 'You are in a form selector and must select a single option. Use the up and down keys to navigate or escape to close.', + } + ), 'euiSuperSelectControl.selectAnOption': ({ selectedValue }: EuiValues) => i18n.translate('core.euiSuperSelectControl.selectAnOption', { defaultMessage: 'Select an option: {selectedValue}, is selected', diff --git a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts index 2e80fbb9d20c0..c1f6ffb5add77 100644 --- a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts +++ b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts @@ -7,7 +7,7 @@ */ import supertest from 'supertest'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { HttpService, InternalHttpServicePreboot, InternalHttpServiceSetup } from '../../http'; import { contextServiceMock } from '../../context/context_service.mock'; import { executionContextServiceMock } from '../../execution_context/execution_context_service.mock'; diff --git a/src/core/server/core_context.mock.ts b/src/core/server/core_context.mock.ts index ddb87d31383c8..4d7b4e1ba5548 100644 --- a/src/core/server/core_context.mock.ts +++ b/src/core/server/core_context.mock.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { CoreContext } from './core_context'; import { Env, IConfigService } from './config'; diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index 7988e81045d17..f252993415afa 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -6,21 +6,16 @@ * Side Public License, v 1. */ -import { Buffer } from 'buffer'; -import { Readable } from 'stream'; - -import { errors } from '@elastic/elasticsearch'; -import type { - TransportRequestOptions, - TransportRequestParams, - DiagnosticResult, - RequestBody, -} from '@elastic/elasticsearch'; +jest.mock('./log_query_and_deprecation.ts', () => ({ + __esModule: true, + instrumentEsQueryAndDeprecationLogger: jest.fn(), +})); import { parseClientOptionsMock, ClientMock } from './configure_client.test.mocks'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import type { ElasticsearchClientConfig } from './client_config'; import { configureClient } from './configure_client'; +import { instrumentEsQueryAndDeprecationLogger } from './log_query_and_deprecation'; const createFakeConfig = ( parts: Partial = {} @@ -36,40 +31,9 @@ const createFakeClient = () => { const client = new actualEs.Client({ nodes: ['http://localhost'], // Enforcing `nodes` because it's mandatory }); - jest.spyOn(client.diagnostic, 'on'); return client; }; -const createApiResponse = ({ - body, - statusCode = 200, - headers = {}, - warnings = [], - params, - requestOptions = {}, -}: { - body: T; - statusCode?: number; - headers?: Record; - warnings?: string[]; - params?: TransportRequestParams; - requestOptions?: TransportRequestOptions; -}): DiagnosticResult => { - return { - body, - statusCode, - headers, - warnings, - meta: { - body, - request: { - params: params!, - options: requestOptions, - } as any, - } as any, - }; -}; - describe('configureClient', () => { let logger: ReturnType; let config: ElasticsearchClientConfig; @@ -84,6 +48,7 @@ describe('configureClient', () => { afterEach(() => { parseClientOptionsMock.mockReset(); ClientMock.mockReset(); + jest.clearAllMocks(); }); it('calls `parseClientOptions` with the correct parameters', () => { @@ -113,366 +78,14 @@ describe('configureClient', () => { expect(client).toBe(ClientMock.mock.results[0].value); }); - it('listens to client on `response` events', () => { + it('calls instrumentEsQueryAndDeprecationLogger', () => { const client = configureClient(config, { logger, type: 'test', scoped: false }); - expect(client.diagnostic.on).toHaveBeenCalledTimes(1); - expect(client.diagnostic.on).toHaveBeenCalledWith('response', expect.any(Function)); - }); - - describe('Client logging', () => { - function createResponseWithBody(body?: RequestBody) { - return createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - querystring: { hello: 'dolly' }, - body, - }, - }); - } - - describe('logs each query', () => { - it('creates a query logger context based on the `type` parameter', () => { - configureClient(createFakeConfig(), { logger, type: 'test123' }); - expect(logger.get).toHaveBeenCalledWith('query', 'test123'); - }); - - it('when request body is an object', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createResponseWithBody({ - seq_no_primary_term: true, - query: { - term: { user: 'kimchy' }, - }, - }); - - client.diagnostic.emit('response', null, response); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "200 - GET /foo?hello=dolly - {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}}", - undefined, - ], - ] - `); - }); - - it('when request body is a string', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createResponseWithBody( - JSON.stringify({ - seq_no_primary_term: true, - query: { - term: { user: 'kimchy' }, - }, - }) - ); - - client.diagnostic.emit('response', null, response); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "200 - GET /foo?hello=dolly - {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}}", - undefined, - ], - ] - `); - }); - - it('when request body is a buffer', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createResponseWithBody( - Buffer.from( - JSON.stringify({ - seq_no_primary_term: true, - query: { - term: { user: 'kimchy' }, - }, - }) - ) - ); - - client.diagnostic.emit('response', null, response); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "200 - GET /foo?hello=dolly - [buffer]", - undefined, - ], - ] - `); - }); - - it('when request body is a readable stream', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createResponseWithBody( - Readable.from( - JSON.stringify({ - seq_no_primary_term: true, - query: { - term: { user: 'kimchy' }, - }, - }) - ) - ); - - client.diagnostic.emit('response', null, response); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "200 - GET /foo?hello=dolly - [stream]", - undefined, - ], - ] - `); - }); - - it('when request body is not defined', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createResponseWithBody(); - - client.diagnostic.emit('response', null, response); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "200 - GET /foo?hello=dolly", - undefined, - ], - ] - `); - }); - - it('properly encode queries', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - querystring: { city: 'Münich' }, - }, - }); - - client.diagnostic.emit('response', null, response); - - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "200 - GET /foo?city=M%C3%BCnich", - undefined, - ], - ] - `); - }); - - it('logs queries even in case of errors', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createApiResponse({ - statusCode: 500, - body: { - error: { - type: 'internal server error', - }, - }, - params: { - method: 'GET', - path: '/foo', - querystring: { hello: 'dolly' }, - body: { - seq_no_primary_term: true, - query: { - term: { user: 'kimchy' }, - }, - }, - }, - }); - client.diagnostic.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "500 - GET /foo?hello=dolly - {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}} [internal server error]: internal server error", - undefined, - ], - ] - `); - }); - - it('logs debug when the client emits an @elastic/elasticsearch error', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createApiResponse({ body: {} }); - client.diagnostic.emit('response', new errors.TimeoutError('message', response), response); - - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "[TimeoutError]: message", - undefined, - ], - ] - `); - }); - - it('logs debug when the client emits an ResponseError returned by elasticsearch', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - const response = createApiResponse({ - statusCode: 400, - headers: {}, - params: { - method: 'GET', - path: '/_path', - querystring: { hello: 'dolly' }, - }, - body: { - error: { - type: 'illegal_argument_exception', - reason: 'request [/_path] contains unrecognized parameter: [name]', - }, - }, - }); - client.diagnostic.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "400 - GET /_path?hello=dolly [illegal_argument_exception]: request [/_path] contains unrecognized parameter: [name]", - undefined, - ], - ] - `); - }); - - it('logs default error info when the error response body is empty', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - let response: DiagnosticResult = createApiResponse({ - statusCode: 400, - headers: {}, - params: { - method: 'GET', - path: '/_path', - }, - body: { - error: {}, - }, - }); - client.diagnostic.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "400 - GET /_path [undefined]: {\\"error\\":{}}", - undefined, - ], - ] - `); - - logger.debug.mockClear(); - - response = createApiResponse({ - statusCode: 400, - headers: {}, - params: { - method: 'GET', - path: '/_path', - }, - body: undefined, - }); - client.diagnostic.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "400 - GET /_path [undefined]: Response Error", - undefined, - ], - ] - `); - }); - - it('adds meta information to logs', () => { - const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - - let response = createApiResponse({ - statusCode: 400, - headers: {}, - params: { - method: 'GET', - path: '/_path', - }, - requestOptions: { - opaqueId: 'opaque-id', - }, - body: { - error: {}, - }, - }); - client.diagnostic.emit('response', null, response); - - expect(loggingSystemMock.collect(logger).debug[0][1]).toMatchInlineSnapshot(` - Object { - "http": Object { - "request": Object { - "id": "opaque-id", - }, - }, - } - `); - - logger.debug.mockClear(); - - response = createApiResponse({ - statusCode: 400, - headers: {}, - params: { - method: 'GET', - path: '/_path', - }, - requestOptions: { - opaqueId: 'opaque-id', - }, - body: {} as any, - }); - client.diagnostic.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).debug[0][1]).toMatchInlineSnapshot(` - Object { - "http": Object { - "request": Object { - "id": "opaque-id", - }, - }, - } - `); - }); + expect(instrumentEsQueryAndDeprecationLogger).toHaveBeenCalledTimes(1); + expect(instrumentEsQueryAndDeprecationLogger).toHaveBeenCalledWith({ + logger, + client, + type: 'test', }); }); }); diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index fc8a06660cc5e..e48a36fa4fe58 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -6,21 +6,17 @@ * Side Public License, v 1. */ -import { Buffer } from 'buffer'; -import { stringify } from 'querystring'; -import { Client, errors, Transport, HttpConnection } from '@elastic/elasticsearch'; +import { Client, Transport, HttpConnection } from '@elastic/elasticsearch'; import type { KibanaClient } from '@elastic/elasticsearch/lib/api/kibana'; import type { TransportRequestParams, TransportRequestOptions, TransportResult, - DiagnosticResult, - RequestBody, } from '@elastic/elasticsearch'; import { Logger } from '../../logging'; import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; -import type { ElasticsearchErrorDetails } from './types'; +import { instrumentEsQueryAndDeprecationLogger } from './log_query_and_deprecation'; const noop = () => undefined; @@ -61,91 +57,8 @@ export const configureClient = ( Transport: KibanaTransport, Connection: HttpConnection, }); - addLogging(client, logger.get('query', type)); - return client as KibanaClient; -}; - -const convertQueryString = (qs: string | Record | undefined): string => { - if (qs === undefined || typeof qs === 'string') { - return qs ?? ''; - } - return stringify(qs); -}; - -function ensureString(body: RequestBody): string { - if (typeof body === 'string') return body; - if (Buffer.isBuffer(body)) return '[buffer]'; - if ('readable' in body && body.readable && typeof body._read === 'function') return '[stream]'; - return JSON.stringify(body); -} - -/** - * Returns a debug message from an Elasticsearch error in the following format: - * [error type] error reason - */ -export function getErrorMessage(error: errors.ElasticsearchClientError): string { - if (error instanceof errors.ResponseError) { - const errorBody = error.meta.body as ElasticsearchErrorDetails; - return `[${errorBody?.error?.type}]: ${errorBody?.error?.reason ?? error.message}`; - } - return `[${error.name}]: ${error.message}`; -} + instrumentEsQueryAndDeprecationLogger({ logger, client, type }); -/** - * returns a string in format: - * - * status code - * method URL - * request body - * - * so it could be copy-pasted into the Dev console - */ -function getResponseMessage(event: DiagnosticResult): string { - const errorMeta = getRequestDebugMeta(event); - const body = errorMeta.body ? `\n${errorMeta.body}` : ''; - return `${errorMeta.statusCode}\n${errorMeta.method} ${errorMeta.url}${body}`; -} - -/** - * Returns stringified debug information from an Elasticsearch request event - * useful for logging in case of an unexpected failure. - */ -export function getRequestDebugMeta(event: DiagnosticResult): { - url: string; - body: string; - statusCode: number | null; - method: string; -} { - const params = event.meta.request.params; - // definition is wrong, `params.querystring` can be either a string or an object - const querystring = convertQueryString(params.querystring); - return { - url: `${params.path}${querystring ? `?${querystring}` : ''}`, - body: params.body ? `${ensureString(params.body)}` : '', - method: params.method, - statusCode: event.statusCode!, - }; -} - -const addLogging = (client: Client, logger: Logger) => { - client.diagnostic.on('response', (error, event) => { - if (event) { - const opaqueId = event.meta.request.options.opaqueId; - const meta = opaqueId - ? { - http: { request: { id: event.meta.request.options.opaqueId } }, - } - : undefined; // do not clutter logs if opaqueId is not present - if (error) { - if (error instanceof errors.ResponseError) { - logger.debug(`${getResponseMessage(event)} ${getErrorMessage(error)}`, meta); - } else { - logger.debug(getErrorMessage(error), meta); - } - } else { - logger.debug(getResponseMessage(event), meta); - } - } - }); + return client as KibanaClient; }; diff --git a/src/core/server/elasticsearch/client/index.ts b/src/core/server/elasticsearch/client/index.ts index 2cf5a0229a489..123c498f1ee21 100644 --- a/src/core/server/elasticsearch/client/index.ts +++ b/src/core/server/elasticsearch/client/index.ts @@ -21,5 +21,6 @@ export type { IScopedClusterClient } from './scoped_cluster_client'; export type { ElasticsearchClientConfig } from './client_config'; export { ClusterClient } from './cluster_client'; export type { IClusterClient, ICustomClusterClient } from './cluster_client'; -export { configureClient, getRequestDebugMeta, getErrorMessage } from './configure_client'; +export { configureClient } from './configure_client'; +export { getRequestDebugMeta, getErrorMessage } from './log_query_and_deprecation'; export { retryCallCluster, migrationRetryCallCluster } from './retry_call_cluster'; diff --git a/src/core/server/elasticsearch/client/log_query_and_deprecation.test.ts b/src/core/server/elasticsearch/client/log_query_and_deprecation.test.ts new file mode 100644 index 0000000000000..30d5d8b87ed1c --- /dev/null +++ b/src/core/server/elasticsearch/client/log_query_and_deprecation.test.ts @@ -0,0 +1,624 @@ +/* + * Copyright 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 { Buffer } from 'buffer'; +import { Readable } from 'stream'; + +import { + Client, + ConnectionRequestParams, + errors, + TransportRequestOptions, + TransportRequestParams, +} from '@elastic/elasticsearch'; +import type { DiagnosticResult, RequestBody } from '@elastic/elasticsearch'; + +import { parseClientOptionsMock, ClientMock } from './configure_client.test.mocks'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; +import { instrumentEsQueryAndDeprecationLogger } from './log_query_and_deprecation'; + +const createApiResponse = ({ + body, + statusCode = 200, + headers = {}, + warnings = null, + params, + requestOptions = {}, +}: { + body: T; + statusCode?: number; + headers?: Record; + warnings?: string[] | null; + params?: TransportRequestParams | ConnectionRequestParams; + requestOptions?: TransportRequestOptions; +}): DiagnosticResult => { + return { + body, + statusCode, + headers, + warnings, + meta: { + body, + request: { + params: params!, + options: requestOptions, + } as any, + } as any, + }; +}; + +const createFakeClient = () => { + const actualEs = jest.requireActual('@elastic/elasticsearch'); + const client = new actualEs.Client({ + nodes: ['http://localhost'], // Enforcing `nodes` because it's mandatory + }); + jest.spyOn(client.diagnostic, 'on'); + return client as Client; +}; + +describe('instrumentQueryAndDeprecationLogger', () => { + let logger: ReturnType; + const client = createFakeClient(); + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + parseClientOptionsMock.mockReturnValue({}); + ClientMock.mockImplementation(() => createFakeClient()); + }); + + afterEach(() => { + parseClientOptionsMock.mockReset(); + ClientMock.mockReset(); + jest.clearAllMocks(); + }); + + function createResponseWithBody(body?: RequestBody) { + return createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + querystring: { hello: 'dolly' }, + body, + }, + }); + } + + it('creates a query logger context based on the `type` parameter', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test123' }); + expect(logger.get).toHaveBeenCalledWith('query', 'test123'); + }); + + describe('logs each query', () => { + it('when request body is an object', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createResponseWithBody({ + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }); + + client.diagnostic.emit('response', null, response); + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo?hello=dolly + {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}}", + undefined, + ], + ] + `); + }); + + it('when request body is a string', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createResponseWithBody( + JSON.stringify({ + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }) + ); + + client.diagnostic.emit('response', null, response); + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo?hello=dolly + {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}}", + undefined, + ], + ] + `); + }); + + it('when request body is a buffer', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createResponseWithBody( + Buffer.from( + JSON.stringify({ + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }) + ) + ); + + client.diagnostic.emit('response', null, response); + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo?hello=dolly + [buffer]", + undefined, + ], + ] + `); + }); + + it('when request body is a readable stream', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createResponseWithBody( + Readable.from( + JSON.stringify({ + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }) + ) + ); + + client.diagnostic.emit('response', null, response); + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo?hello=dolly + [stream]", + undefined, + ], + ] + `); + }); + + it('when request body is not defined', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createResponseWithBody(); + + client.diagnostic.emit('response', null, response); + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo?hello=dolly", + undefined, + ], + ] + `); + }); + + it('properly encode queries', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + querystring: { city: 'Münich' }, + }, + }); + + client.diagnostic.emit('response', null, response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo?city=M%C3%BCnich", + undefined, + ], + ] + `); + }); + + it('logs queries even in case of errors', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + statusCode: 500, + body: { + error: { + type: 'internal server error', + }, + }, + params: { + method: 'GET', + path: '/foo', + querystring: { hello: 'dolly' }, + body: { + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }, + }, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "500 + GET /foo?hello=dolly + {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}} [internal server error]: internal server error", + undefined, + ], + ] + `); + }); + + it('logs debug when the client emits an @elastic/elasticsearch error', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ body: {} }); + client.diagnostic.emit('response', new errors.TimeoutError('message', response), response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "[TimeoutError]: message", + undefined, + ], + ] + `); + }); + + it('logs debug when the client emits an ResponseError returned by elasticsearch', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, + }, + body: { + error: { + type: 'illegal_argument_exception', + reason: 'request [/_path] contains unrecognized parameter: [name]', + }, + }, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "400 + GET /_path?hello=dolly [illegal_argument_exception]: request [/_path] contains unrecognized parameter: [name]", + undefined, + ], + ] + `); + }); + + it('logs default error info when the error response body is empty', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + let response: DiagnosticResult = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + }, + body: { + error: {}, + }, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "400 + GET /_path [undefined]: {\\"error\\":{}}", + undefined, + ], + ] + `); + + logger.debug.mockClear(); + + response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + }, + body: undefined, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "400 + GET /_path [undefined]: Response Error", + undefined, + ], + ] + `); + }); + + it('adds meta information to logs', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + let response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + }, + requestOptions: { + opaqueId: 'opaque-id', + }, + body: { + error: {}, + }, + }); + client.diagnostic.emit('response', null, response); + + expect(loggingSystemMock.collect(logger).debug[0][1]).toMatchInlineSnapshot(` + Object { + "http": Object { + "request": Object { + "id": "opaque-id", + }, + }, + } + `); + + logger.debug.mockClear(); + + response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + }, + requestOptions: { + opaqueId: 'opaque-id', + }, + body: {} as any, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).debug[0][1]).toMatchInlineSnapshot(` + Object { + "http": Object { + "request": Object { + "id": "opaque-id", + }, + }, + } + `); + }); + }); + + describe('deprecation warnings from response headers', () => { + it('does not log when no deprecation warning header is returned', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + statusCode: 200, + warnings: null, + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, + }, + body: { + hits: [ + { + _source: 'may the source be with you', + }, + ], + }, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + // One debug log entry from 'elasticsearch.query' context + expect(loggingSystemMock.collect(logger).debug.length).toEqual(1); + expect(loggingSystemMock.collect(logger).info).toEqual([]); + }); + + it('does not log when warning header comes from a warn-agent that is not elasticsearch', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + statusCode: 200, + warnings: [ + '299 nginx/2.3.1 "GET /_path is deprecated"', + '299 nginx/2.3.1 "GET hello query param is deprecated"', + ], + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, + }, + body: { + hits: [ + { + _source: 'may the source be with you', + }, + ], + }, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + // One debug log entry from 'elasticsearch.query' context + expect(loggingSystemMock.collect(logger).debug.length).toEqual(1); + expect(loggingSystemMock.collect(logger).info).toEqual([]); + }); + + it('logs error when the client receives an Elasticsearch error response for a deprecated request originating from a user', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + statusCode: 400, + warnings: ['299 Elasticsearch-8.1.0 "GET /_path is deprecated"'], + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, + }, + body: { + error: { + type: 'illegal_argument_exception', + reason: 'request [/_path] contains unrecognized parameter: [name]', + }, + }, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).info).toEqual([]); + // Test debug[1] since theree is one log entry from 'elasticsearch.query' context + expect(loggingSystemMock.collect(logger).debug[1][0]).toMatch( + 'Elasticsearch deprecation: 299 Elasticsearch-8.1.0 "GET /_path is deprecated"' + ); + expect(loggingSystemMock.collect(logger).debug[1][0]).toMatch('Origin:user'); + expect(loggingSystemMock.collect(logger).debug[1][0]).toMatch(/Stack trace:\n.*at/); + expect(loggingSystemMock.collect(logger).debug[1][0]).toMatch( + /Query:\n.*400\n.*GET \/_path\?hello\=dolly \[illegal_argument_exception\]: request \[\/_path\] contains unrecognized parameter: \[name\]/ + ); + }); + + it('logs warning when the client receives an Elasticsearch error response for a deprecated request originating from kibana', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + statusCode: 400, + warnings: ['299 Elasticsearch-8.1.0 "GET /_path is deprecated"'], + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, + // Set the request header to indicate to Elasticsearch that this is a request over which users have no control + headers: { 'x-elastic-product-origin': 'kibana' }, + }, + body: { + error: { + type: 'illegal_argument_exception', + reason: 'request [/_path] contains unrecognized parameter: [name]', + }, + }, + }); + client.diagnostic.emit('response', new errors.ResponseError(response), response); + + // One debug log entry from 'elasticsearch.query' context + expect(loggingSystemMock.collect(logger).debug.length).toEqual(1); + expect(loggingSystemMock.collect(logger).info[0][0]).toMatch( + 'Elasticsearch deprecation: 299 Elasticsearch-8.1.0 "GET /_path is deprecated"' + ); + expect(loggingSystemMock.collect(logger).info[0][0]).toMatch('Origin:kibana'); + expect(loggingSystemMock.collect(logger).info[0][0]).toMatch(/Stack trace:\n.*at/); + expect(loggingSystemMock.collect(logger).info[0][0]).toMatch( + /Query:\n.*400\n.*GET \/_path\?hello\=dolly \[illegal_argument_exception\]: request \[\/_path\] contains unrecognized parameter: \[name\]/ + ); + }); + + it('logs error when the client receives an Elasticsearch success response for a deprecated request originating from a user', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + statusCode: 200, + warnings: ['299 Elasticsearch-8.1.0 "GET /_path is deprecated"'], + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, + }, + body: { + hits: [ + { + _source: 'may the source be with you', + }, + ], + }, + }); + client.diagnostic.emit('response', null, response); + + expect(loggingSystemMock.collect(logger).info).toEqual([]); + // Test debug[1] since theree is one log entry from 'elasticsearch.query' context + expect(loggingSystemMock.collect(logger).debug[1][0]).toMatch( + 'Elasticsearch deprecation: 299 Elasticsearch-8.1.0 "GET /_path is deprecated"' + ); + expect(loggingSystemMock.collect(logger).debug[1][0]).toMatch('Origin:user'); + expect(loggingSystemMock.collect(logger).debug[1][0]).toMatch(/Stack trace:\n.*at/); + expect(loggingSystemMock.collect(logger).debug[1][0]).toMatch( + /Query:\n.*200\n.*GET \/_path\?hello\=dolly/ + ); + }); + + it('logs warning when the client receives an Elasticsearch success response for a deprecated request originating from kibana', () => { + instrumentEsQueryAndDeprecationLogger({ logger, client, type: 'test type' }); + + const response = createApiResponse({ + statusCode: 200, + warnings: ['299 Elasticsearch-8.1.0 "GET /_path is deprecated"'], + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, + // Set the request header to indicate to Elasticsearch that this is a request over which users have no control + headers: { 'x-elastic-product-origin': 'kibana' }, + }, + body: { + hits: [ + { + _source: 'may the source be with you', + }, + ], + }, + }); + client.diagnostic.emit('response', null, response); + + // One debug log entry from 'elasticsearch.query' context + expect(loggingSystemMock.collect(logger).debug.length).toEqual(1); + expect(loggingSystemMock.collect(logger).info[0][0]).toMatch( + 'Elasticsearch deprecation: 299 Elasticsearch-8.1.0 "GET /_path is deprecated"' + ); + expect(loggingSystemMock.collect(logger).info[0][0]).toMatch('Origin:kibana'); + expect(loggingSystemMock.collect(logger).info[0][0]).toMatch(/Stack trace:\n.*at/); + expect(loggingSystemMock.collect(logger).info[0][0]).toMatch( + /Query:\n.*200\n.*GET \/_path\?hello\=dolly/ + ); + }); + }); +}); diff --git a/src/core/server/elasticsearch/client/log_query_and_deprecation.ts b/src/core/server/elasticsearch/client/log_query_and_deprecation.ts new file mode 100644 index 0000000000000..fc5a0fa6e1111 --- /dev/null +++ b/src/core/server/elasticsearch/client/log_query_and_deprecation.ts @@ -0,0 +1,143 @@ +/* + * Copyright 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 { Buffer } from 'buffer'; +import { stringify } from 'querystring'; +import { errors, DiagnosticResult, RequestBody, Client } from '@elastic/elasticsearch'; +import type { ElasticsearchErrorDetails } from './types'; +import { Logger } from '../../logging'; + +const convertQueryString = (qs: string | Record | undefined): string => { + if (qs === undefined || typeof qs === 'string') { + return qs ?? ''; + } + return stringify(qs); +}; + +function ensureString(body: RequestBody): string { + if (typeof body === 'string') return body; + if (Buffer.isBuffer(body)) return '[buffer]'; + if ('readable' in body && body.readable && typeof body._read === 'function') return '[stream]'; + return JSON.stringify(body); +} + +/** + * Returns a debug message from an Elasticsearch error in the following format: + * [error type] error reason + */ +export function getErrorMessage(error: errors.ElasticsearchClientError): string { + if (error instanceof errors.ResponseError) { + const errorBody = error.meta.body as ElasticsearchErrorDetails; + return `[${errorBody?.error?.type}]: ${errorBody?.error?.reason ?? error.message}`; + } + return `[${error.name}]: ${error.message}`; +} + +/** + * returns a string in format: + * + * status code + * method URL + * request body + * + * so it could be copy-pasted into the Dev console + */ +function getResponseMessage(event: DiagnosticResult): string { + const errorMeta = getRequestDebugMeta(event); + const body = errorMeta.body ? `\n${errorMeta.body}` : ''; + return `${errorMeta.statusCode}\n${errorMeta.method} ${errorMeta.url}${body}`; +} + +/** + * Returns stringified debug information from an Elasticsearch request event + * useful for logging in case of an unexpected failure. + */ +export function getRequestDebugMeta(event: DiagnosticResult): { + url: string; + body: string; + statusCode: number | null; + method: string; +} { + const params = event.meta.request.params; + // definition is wrong, `params.querystring` can be either a string or an object + const querystring = convertQueryString(params.querystring); + return { + url: `${params.path}${querystring ? `?${querystring}` : ''}`, + body: params.body ? `${ensureString(params.body)}` : '', + method: params.method, + statusCode: event.statusCode!, + }; +} + +/** HTTP Warning headers have the following syntax: + * (where warn-code is a three digit number) + * This function tests if a warning comes from an Elasticsearch warn-agent + * */ +const isEsWarning = (warning: string) => /\d\d\d Elasticsearch-/.test(warning); + +export const instrumentEsQueryAndDeprecationLogger = ({ + logger, + client, + type, +}: { + logger: Logger; + client: Client; + type: string; +}) => { + const queryLogger = logger.get('query', type); + const deprecationLogger = logger.get('deprecation'); + client.diagnostic.on('response', (error, event) => { + if (event) { + const opaqueId = event.meta.request.options.opaqueId; + const meta = opaqueId + ? { + http: { request: { id: event.meta.request.options.opaqueId } }, + } + : undefined; // do not clutter logs if opaqueId is not present + let queryMsg = ''; + if (error) { + if (error instanceof errors.ResponseError) { + queryMsg = `${getResponseMessage(event)} ${getErrorMessage(error)}`; + } else { + queryMsg = getErrorMessage(error); + } + } else { + queryMsg = getResponseMessage(event); + } + + queryLogger.debug(queryMsg, meta); + + if (event.warnings && event.warnings.filter(isEsWarning).length > 0) { + // Plugins can explicitly mark requests as originating from a user by + // removing the `'x-elastic-product-origin': 'kibana'` header that's + // added by default. User requests will be shown to users in the + // upgrade assistant UI as an action item that has to be addressed + // before they upgrade. + // Kibana requests will be hidden from the upgrade assistant UI and are + // only logged to help developers maintain their plugins + const requestOrigin = + (event.meta.request.params.headers != null && + (event.meta.request.params.headers[ + 'x-elastic-product-origin' + ] as unknown as string)) === 'kibana' + ? 'kibana' + : 'user'; + + // Strip the first 5 stack trace lines as these are irrelavent to finding the call site + const stackTrace = new Error().stack?.split('\n').slice(5).join('\n'); + + const deprecationMsg = `Elasticsearch deprecation: ${event.warnings}\nOrigin:${requestOrigin}\nStack trace:\n${stackTrace}\nQuery:\n${queryMsg}`; + if (requestOrigin === 'kibana') { + deprecationLogger.info(deprecationMsg); + } else { + deprecationLogger.debug(deprecationMsg); + } + } + } + }); +}; diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 3b75d19b80a10..ce5672ad30519 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -21,7 +21,7 @@ import { MockClusterClient, isScriptingEnabledMock } from './elasticsearch_servi import type { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { BehaviorSubject } from 'rxjs'; import { first } from 'rxjs/operators'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Env } from '../config'; import { configServiceMock, getEnvOptions } from '../config/mocks'; import { CoreContext } from '../core_context'; diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index ad05d37c81e99..8e2cd58733faf 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -8,7 +8,7 @@ import { parse as parseCookie } from 'tough-cookie'; import supertest from 'supertest'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { ByteSizeValue } from '@kbn/config-schema'; import { BehaviorSubject } from 'rxjs'; diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 4955d19668580..3a387cdfd5e35 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -10,7 +10,7 @@ import { mockHttpServer } from './http_service.test.mocks'; import { noop } from 'lodash'; import { BehaviorSubject } from 'rxjs'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { getEnvOptions } from '../config/mocks'; import { HttpService } from '.'; import { HttpConfigType, config } from './http_config'; diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index 4e1a88e967f8f..8a8c545b365b3 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -8,7 +8,7 @@ import { BehaviorSubject } from 'rxjs'; import moment from 'moment'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { ByteSizeValue } from '@kbn/config-schema'; import { Env } from '../config'; import { HttpService } from './http_service'; diff --git a/src/core/server/metrics/logging/get_ops_metrics_log.test.ts b/src/core/server/metrics/logging/get_ops_metrics_log.test.ts index cba188c94c74e..3fd3c4a7a24d6 100644 --- a/src/core/server/metrics/logging/get_ops_metrics_log.test.ts +++ b/src/core/server/metrics/logging/get_ops_metrics_log.test.ts @@ -42,6 +42,7 @@ const testMetrics = { memory: { heap: { used_in_bytes: 100 } }, uptime_in_millis: 1500, event_loop_delay: 50, + event_loop_delay_histogram: { percentiles: { '50': 50, '75': 75, '95': 95, '99': 99 } }, }, os: { load: { @@ -56,7 +57,7 @@ describe('getEcsOpsMetricsLog', () => { it('provides correctly formatted message', () => { const result = getEcsOpsMetricsLog(createMockOpsMetrics(testMetrics)); expect(result.message).toMatchInlineSnapshot( - `"memory: 100.0B uptime: 0:00:01 load: [10.00,20.00,30.00] delay: 50.000"` + `"memory: 100.0B uptime: 0:00:01 load: [10.00,20.00,30.00] mean delay: 50.000 delay histogram: { 50: 50.000; 95: 95.000; 99: 99.000 }"` ); }); @@ -70,6 +71,7 @@ describe('getEcsOpsMetricsLog', () => { const missingMetrics = { ...baseMetrics, process: {}, + processes: [], os: {}, } as unknown as OpsMetrics; const logMeta = getEcsOpsMetricsLog(missingMetrics); @@ -77,39 +79,41 @@ describe('getEcsOpsMetricsLog', () => { }); it('provides an ECS-compatible response', () => { - const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics()); - expect(logMeta).toMatchInlineSnapshot(` + const logMeta = getEcsOpsMetricsLog(createMockOpsMetrics(testMetrics)); + expect(logMeta.meta).toMatchInlineSnapshot(` Object { - "message": "memory: 1.0B load: [1.00,1.00,1.00] delay: 1.000", - "meta": Object { - "event": Object { - "category": Array [ - "process", - "host", - ], - "kind": "metric", - "type": Array [ - "info", - ], - }, - "host": Object { - "os": Object { - "load": Object { - "15m": 1, - "1m": 1, - "5m": 1, - }, + "event": Object { + "category": Array [ + "process", + "host", + ], + "kind": "metric", + "type": Array [ + "info", + ], + }, + "host": Object { + "os": Object { + "load": Object { + "15m": 30, + "1m": 10, + "5m": 20, }, }, - "process": Object { - "eventLoopDelay": 1, - "memory": Object { - "heap": Object { - "usedInBytes": 1, - }, + }, + "process": Object { + "eventLoopDelay": 50, + "eventLoopDelayHistogram": Object { + "50": 50, + "95": 95, + "99": 99, + }, + "memory": Object { + "heap": Object { + "usedInBytes": 100, }, - "uptime": 0, }, + "uptime": 1, }, } `); diff --git a/src/core/server/metrics/logging/get_ops_metrics_log.ts b/src/core/server/metrics/logging/get_ops_metrics_log.ts index 7e13f35889ec7..6211407ae86f0 100644 --- a/src/core/server/metrics/logging/get_ops_metrics_log.ts +++ b/src/core/server/metrics/logging/get_ops_metrics_log.ts @@ -30,10 +30,29 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics) { // HH:mm:ss message format for backward compatibility const uptimeValMsg = uptimeVal ? `uptime: ${numeral(uptimeVal).format('00:00:00')} ` : ''; - // Event loop delay is in ms + // Event loop delay metrics are in ms const eventLoopDelayVal = process?.event_loop_delay; const eventLoopDelayValMsg = eventLoopDelayVal - ? `delay: ${numeral(process?.event_loop_delay).format('0.000')}` + ? `mean delay: ${numeral(process?.event_loop_delay).format('0.000')}` + : ''; + + const eventLoopDelayPercentiles = process?.event_loop_delay_histogram?.percentiles; + + // Extract 50th, 95th and 99th percentiles for log meta + const eventLoopDelayHistVals = eventLoopDelayPercentiles + ? { + 50: eventLoopDelayPercentiles[50], + 95: eventLoopDelayPercentiles[95], + 99: eventLoopDelayPercentiles[99], + } + : undefined; + // Format message from 50th, 95th and 99th percentiles + const eventLoopDelayHistMsg = eventLoopDelayPercentiles + ? ` delay histogram: { 50: ${numeral(eventLoopDelayPercentiles['50']).format( + '0.000' + )}; 95: ${numeral(eventLoopDelayPercentiles['95']).format('0.000')}; 99: ${numeral( + eventLoopDelayPercentiles['99'] + ).format('0.000')} }` : ''; const loadEntries = { @@ -65,6 +84,7 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics) { }, }, eventLoopDelay: eventLoopDelayVal, + eventLoopDelayHistogram: eventLoopDelayHistVals, }, host: { os: { @@ -75,7 +95,13 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics) { }; return { - message: `${processMemoryUsedInBytesMsg}${uptimeValMsg}${loadValsMsg}${eventLoopDelayValMsg}`, + message: [ + processMemoryUsedInBytesMsg, + uptimeValMsg, + loadValsMsg, + eventLoopDelayValMsg, + eventLoopDelayHistMsg, + ].join(''), meta, }; } diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts index d7de41fd7ccf7..27043b8fa2c8a 100644 --- a/src/core/server/metrics/metrics_service.test.ts +++ b/src/core/server/metrics/metrics_service.test.ts @@ -203,6 +203,7 @@ describe('MetricsService', () => { }, "process": Object { "eventLoopDelay": undefined, + "eventLoopDelayHistogram": undefined, "memory": Object { "heap": Object { "usedInBytes": undefined, diff --git a/src/core/server/plugins/discovery/plugins_discovery.test.ts b/src/core/server/plugins/discovery/plugins_discovery.test.ts index 958e051d0476d..a6ffdff4422be 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.test.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.test.ts @@ -7,7 +7,7 @@ */ // must be before mocks imports to avoid conflicting with `REPO_ROOT` accessor. -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { mockPackage, scanPluginSearchPathsMock } from './plugins_discovery.test.mocks'; import mockFs from 'mock-fs'; import { loggingSystemMock } from '../../logging/logging_system.mock'; diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index 4170d9422f277..ebbb3fa473b6d 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -7,7 +7,7 @@ */ // must be before mocks imports to avoid conflicting with `REPO_ROOT` accessor. -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { mockPackage, mockDiscover } from './plugins_service.test.mocks'; import { join } from 'path'; diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index 513e893992005..92cbda2a69cfe 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -8,7 +8,7 @@ import { join } from 'path'; import { BehaviorSubject } from 'rxjs'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { schema } from '@kbn/config-schema'; import { Env } from '../config'; diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 867d4d978314b..7bcf392ed510b 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -8,7 +8,7 @@ import { duration } from 'moment'; import { first } from 'rxjs/operators'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { fromRoot } from '@kbn/utils'; import { createPluginInitializerContext, diff --git a/src/core/server/plugins/plugins_config.test.ts b/src/core/server/plugins/plugins_config.test.ts index d65b057fb65c0..b9225054e63ef 100644 --- a/src/core/server/plugins/plugins_config.test.ts +++ b/src/core/server/plugins/plugins_config.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { getEnvOptions } from '../config/mocks'; import { PluginsConfig, PluginsConfigType } from './plugins_config'; import { Env } from '../config'; diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 0c077d732c67b..5a05817d2111f 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -11,7 +11,8 @@ import { mockDiscover, mockPackage } from './plugins_service.test.mocks'; import { resolve, join } from 'path'; import { BehaviorSubject, from } from 'rxjs'; import { schema } from '@kbn/config-schema'; -import { createAbsolutePathSerializer, REPO_ROOT } from '@kbn/dev-utils'; +import { createAbsolutePathSerializer } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { ConfigPath, ConfigService, Env } from '../config'; import { rawConfigServiceMock, getEnvOptions } from '../config/mocks'; diff --git a/src/core/server/plugins/plugins_system.test.ts b/src/core/server/plugins/plugins_system.test.ts index 4cd8e4c551bea..3d8a47005b362 100644 --- a/src/core/server/plugins/plugins_system.test.ts +++ b/src/core/server/plugins/plugins_system.test.ts @@ -14,7 +14,7 @@ import { import { BehaviorSubject } from 'rxjs'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Env } from '../config'; import { configServiceMock, getEnvOptions } from '../config/mocks'; import { CoreContext } from '../core_context'; diff --git a/src/core/server/preboot/preboot_service.test.ts b/src/core/server/preboot/preboot_service.test.ts index dd4b1cb7d1df0..77242f0c5765f 100644 --- a/src/core/server/preboot/preboot_service.test.ts +++ b/src/core/server/preboot/preboot_service.test.ts @@ -7,7 +7,7 @@ */ import { nextTick } from '@kbn/test/jest'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { LoggerFactory } from '@kbn/logging'; import { Env } from '@kbn/config'; import { getEnvOptions } from '../config/mocks'; diff --git a/src/core/server/root/index.test.ts b/src/core/server/root/index.test.ts index 7eba051a128f0..6ea3e05b9c2c2 100644 --- a/src/core/server/root/index.test.ts +++ b/src/core/server/root/index.test.ts @@ -10,7 +10,7 @@ import { rawConfigService, configService, logger, mockServer } from './index.tes import { BehaviorSubject } from 'rxjs'; import { filter, first } from 'rxjs/operators'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { getEnvOptions } from '../config/mocks'; import { Root } from '.'; import { Env } from '../config'; diff --git a/src/core/server/saved_objects/migrations/integration_tests/7.7.2_xpack_100k.test.ts b/src/core/server/saved_objects/migrations/integration_tests/7.7.2_xpack_100k.test.ts index c22c6154c2605..139cd298d28ed 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/7.7.2_xpack_100k.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/7.7.2_xpack_100k.test.ts @@ -8,7 +8,7 @@ import path from 'path'; import { unlink } from 'fs/promises'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Env } from '@kbn/config'; import { getEnvOptions } from '../../../config/mocks'; import * as kbnTestServer from '../../../../test_helpers/kbn_server'; diff --git a/src/core/server/saved_objects/migrations/integration_tests/7_13_0_failed_action_tasks.test.ts b/src/core/server/saved_objects/migrations/integration_tests/7_13_0_failed_action_tasks.test.ts index 2def8e375c81f..479b1e78e1b72 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/7_13_0_failed_action_tasks.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/7_13_0_failed_action_tasks.test.ts @@ -19,8 +19,7 @@ async function removeLogFile() { await fs.unlink(logFilePath).catch(() => void 0); } -// FLAKY: https://github.com/elastic/kibana/issues/118626 -describe.skip('migration from 7.13 to 7.14+ with many failed action_tasks', () => { +describe('migration from 7.13 to 7.14+ with many failed action_tasks', () => { let esServer: kbnTestServer.TestElasticsearchUtils; let root: Root; let startES: () => Promise; diff --git a/src/core/server/saved_objects/migrations/integration_tests/migration_from_older_v1.test.ts b/src/core/server/saved_objects/migrations/integration_tests/migration_from_older_v1.test.ts index 0ed9262017263..c341463b78910 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/migration_from_older_v1.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/migration_from_older_v1.test.ts @@ -10,7 +10,7 @@ import Path from 'path'; import Fs from 'fs'; import Util from 'util'; import Semver from 'semver'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Env } from '@kbn/config'; import { getEnvOptions } from '../../../config/mocks'; import * as kbnTestServer from '../../../../test_helpers/kbn_server'; diff --git a/src/core/server/saved_objects/migrations/integration_tests/migration_from_same_v1.test.ts b/src/core/server/saved_objects/migrations/integration_tests/migration_from_same_v1.test.ts index 15d985daccba6..34d1317755c14 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/migration_from_same_v1.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/migration_from_same_v1.test.ts @@ -10,7 +10,7 @@ import Path from 'path'; import Fs from 'fs'; import Util from 'util'; import Semver from 'semver'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Env } from '@kbn/config'; import { getEnvOptions } from '../../../config/mocks'; import * as kbnTestServer from '../../../../test_helpers/kbn_server'; diff --git a/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts b/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts index 7597657e7706c..4ff66151db925 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts @@ -34,6 +34,7 @@ const previouslyRegisteredTypes = [ 'cases-sub-case', 'cases-user-actions', 'config', + 'connector_token', 'core-usage-stats', 'dashboard', 'endpoint:user-artifact', diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index a4f6c019c9624..a8bda95af46f9 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -19,7 +19,7 @@ import { import { BehaviorSubject } from 'rxjs'; import { RawPackageInfo } from '@kbn/config'; import { ByteSizeValue } from '@kbn/config-schema'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { SavedObjectsService } from './saved_objects_service'; import { mockCoreContext } from '../core_context.mock'; diff --git a/src/core/server/saved_objects/service/lib/repository.test.ts b/src/core/server/saved_objects/service/lib/repository.test.ts index 1668df7a82253..ebab5898a0eb9 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.ts +++ b/src/core/server/saved_objects/service/lib/repository.test.ts @@ -2272,7 +2272,16 @@ describe('SavedObjectsRepository', () => { it(`self-generates an id if none is provided`, async () => { await createSuccess(type, attributes); - expect(client.create).toHaveBeenCalledWith( + expect(client.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + }), + expect.anything() + ); + await createSuccess(type, attributes, { id: '' }); + expect(client.create).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), }), @@ -4161,6 +4170,13 @@ describe('SavedObjectsRepository', () => { await test({}); }); + it(`throws when id is empty`, async () => { + await expect( + savedObjectsRepository.incrementCounter(type, '', counterFields) + ).rejects.toThrowError(createBadRequestError('id cannot be empty')); + expect(client.update).not.toHaveBeenCalled(); + }); + it(`throws when counterField is not CounterField type`, async () => { const test = async (field: unknown[]) => { await expect( @@ -4687,6 +4703,13 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); + it(`throws when id is empty`, async () => { + await expect(savedObjectsRepository.update(type, '', attributes)).rejects.toThrowError( + createBadRequestError('id cannot be empty') + ); + expect(client.update).not.toHaveBeenCalled(); + }); + it(`throws when ES is unable to find the document during get`, async () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 53bc6f158bf93..9af85499295b5 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -303,7 +303,6 @@ export class SavedObjectsRepository { options: SavedObjectsCreateOptions = {} ): Promise> { const { - id = SavedObjectsUtils.generateId(), migrationVersion, coreMigrationVersion, overwrite = false, @@ -313,6 +312,7 @@ export class SavedObjectsRepository { initialNamespaces, version, } = options; + const id = options.id || SavedObjectsUtils.generateId(); const namespace = normalizeNamespace(options.namespace); this.validateInitialNamespaces(type, initialNamespaces); @@ -1231,6 +1231,9 @@ export class SavedObjectsRepository { if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } + if (!id) { + throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID + } const { version, references, upsert, refresh = DEFAULT_REFRESH_SETTING } = options; const namespace = normalizeNamespace(options.namespace); @@ -1754,6 +1757,10 @@ export class SavedObjectsRepository { upsertAttributes, } = options; + if (!id) { + throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID + } + const normalizedCounterFields = counterFields.map((counterField) => { /** * no counterField configs provided, instead a field name string was passed. diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 112693aae0279..48547883d5f67 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -26,7 +26,7 @@ import { } from './server.test.mocks'; import { BehaviorSubject } from 'rxjs'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { rawConfigServiceMock, getEnvOptions } from './config/mocks'; import { Env } from './config'; import { Server } from './server'; diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index ef635e90dac70..3f85beb2acec6 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -7,7 +7,7 @@ */ import { Env } from '@kbn/config'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { getEnvOptions } from '../../config/mocks'; import { startServers, stopServers } from './lib'; import { docExistsSuite } from './doc_exists'; diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 58720be637e2f..c326c7a35df63 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { createTestEsCluster, CreateTestEsClusterOptions, diff --git a/src/core/types/elasticsearch/search.ts b/src/core/types/elasticsearch/search.ts index c28bf3c258f77..ac93a45da3258 100644 --- a/src/core/types/elasticsearch/search.ts +++ b/src/core/types/elasticsearch/search.ts @@ -9,6 +9,11 @@ import { ValuesType, UnionToIntersection } from 'utility-types'; import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +interface AggregationsAggregationContainer extends Record { + aggs?: any; + aggregations?: any; +} + type InvalidAggregationRequest = unknown; // ensures aggregations work with requests where aggregation options are a union type, @@ -31,7 +36,7 @@ type KeysOfSources = T extends [any] ? KeyOfSource & KeyOfSource & KeyOfSource & KeyOfSource : Record; -type CompositeKeysOf = +type CompositeKeysOf = TAggregationContainer extends { composite: { sources: [...infer TSource] }; } @@ -40,7 +45,7 @@ type CompositeKeysOf = +type TopMetricKeysOf = TAggregationContainer extends { top_metrics: { metrics: { field: infer TField } } } ? TField : TAggregationContainer extends { top_metrics: { metrics: Array<{ field: infer TField }> } } @@ -92,17 +97,9 @@ type HitsOf< > >; -type AggregationTypeName = Exclude< - keyof estypes.AggregationsAggregationContainer, - 'aggs' | 'aggregations' ->; +type AggregationMap = Partial>; -type AggregationMap = Partial>; - -type TopLevelAggregationRequest = Pick< - estypes.AggregationsAggregationContainer, - 'aggs' | 'aggregations' ->; +type TopLevelAggregationRequest = Pick; type MaybeKeyed< TAggregationContainer, @@ -113,448 +110,460 @@ type MaybeKeyed< : { buckets: TBucket[] }; export type AggregateOf< - TAggregationContainer extends estypes.AggregationsAggregationContainer, + TAggregationContainer extends AggregationsAggregationContainer, TDocument -> = (Record & { - adjacency_matrix: { - buckets: Array< - { - key: string; - doc_count: number; - } & SubAggregateOf - >; - }; - auto_date_histogram: { - interval: string; - buckets: Array< - { - key: number; - key_as_string: string; - doc_count: number; - } & SubAggregateOf - >; - }; - avg: { - value: number | null; - value_as_string?: string; - }; - avg_bucket: { - value: number | null; - }; - boxplot: { - min: number | null; - max: number | null; - q1: number | null; - q2: number | null; - q3: number | null; - }; - bucket_script: { - value: unknown; - }; - cardinality: { - value: number; - }; - children: { - doc_count: number; - } & SubAggregateOf; - composite: { - after_key: CompositeKeysOf; - buckets: Array< - { +> = ValuesType< + Pick< + Record & { + adjacency_matrix: { + buckets: Array< + { + key: string; + doc_count: number; + } & SubAggregateOf + >; + }; + auto_date_histogram: { + interval: string; + buckets: Array< + { + key: number; + key_as_string: string; + doc_count: number; + } & SubAggregateOf + >; + }; + avg: { + value: number | null; + value_as_string?: string; + }; + avg_bucket: { + value: number | null; + }; + boxplot: { + min: number | null; + max: number | null; + q1: number | null; + q2: number | null; + q3: number | null; + }; + bucket_script: { + value: unknown; + }; + cardinality: { + value: number; + }; + children: { doc_count: number; - key: CompositeKeysOf; - } & SubAggregateOf - >; - }; - cumulative_cardinality: { - value: number; - }; - cumulative_sum: { - value: number; - }; - date_histogram: MaybeKeyed< - TAggregationContainer, - { - key: number; - key_as_string: string; - doc_count: number; - } & SubAggregateOf - >; - date_range: MaybeKeyed< - TAggregationContainer, - Partial<{ from: string | number; from_as_string: string }> & - Partial<{ to: string | number; to_as_string: string }> & { + } & SubAggregateOf; + composite: { + after_key: CompositeKeysOf; + buckets: Array< + { + doc_count: number; + key: CompositeKeysOf; + } & SubAggregateOf + >; + }; + cumulative_cardinality: { + value: number; + }; + cumulative_sum: { + value: number; + }; + date_histogram: MaybeKeyed< + TAggregationContainer, + { + key: number; + key_as_string: string; + doc_count: number; + } & SubAggregateOf + >; + date_range: MaybeKeyed< + TAggregationContainer, + Partial<{ from: string | number; from_as_string: string }> & + Partial<{ to: string | number; to_as_string: string }> & { + doc_count: number; + key: string; + } + >; + derivative: + | { + value: number | null; + } + | undefined; + extended_stats: { + count: number; + min: number | null; + max: number | null; + avg: number | null; + sum: number; + sum_of_squares: number | null; + variance: number | null; + variance_population: number | null; + variance_sampling: number | null; + std_deviation: number | null; + std_deviation_population: number | null; + std_deviation_sampling: number | null; + std_deviation_bounds: { + upper: number | null; + lower: number | null; + upper_population: number | null; + lower_population: number | null; + upper_sampling: number | null; + lower_sampling: number | null; + }; + } & ( + | { + min_as_string: string; + max_as_string: string; + avg_as_string: string; + sum_of_squares_as_string: string; + variance_population_as_string: string; + variance_sampling_as_string: string; + std_deviation_as_string: string; + std_deviation_population_as_string: string; + std_deviation_sampling_as_string: string; + std_deviation_bounds_as_string: { + upper: string; + lower: string; + upper_population: string; + lower_population: string; + upper_sampling: string; + lower_sampling: string; + }; + } + | {} + ); + extended_stats_bucket: { + count: number; + min: number | null; + max: number | null; + avg: number | null; + sum: number | null; + sum_of_squares: number | null; + variance: number | null; + variance_population: number | null; + variance_sampling: number | null; + std_deviation: number | null; + std_deviation_population: number | null; + std_deviation_sampling: number | null; + std_deviation_bounds: { + upper: number | null; + lower: number | null; + upper_population: number | null; + lower_population: number | null; + upper_sampling: number | null; + lower_sampling: number | null; + }; + }; + filter: { doc_count: number; - key: string; - } - >; - derivative: - | { - value: number | null; - } - | undefined; - extended_stats: { - count: number; - min: number | null; - max: number | null; - avg: number | null; - sum: number; - sum_of_squares: number | null; - variance: number | null; - variance_population: number | null; - variance_sampling: number | null; - std_deviation: number | null; - std_deviation_population: number | null; - std_deviation_sampling: number | null; - std_deviation_bounds: { - upper: number | null; - lower: number | null; - upper_population: number | null; - lower_population: number | null; - upper_sampling: number | null; - lower_sampling: number | null; - }; - } & ( - | { - min_as_string: string; - max_as_string: string; - avg_as_string: string; - sum_of_squares_as_string: string; - variance_population_as_string: string; - variance_sampling_as_string: string; - std_deviation_as_string: string; - std_deviation_population_as_string: string; - std_deviation_sampling_as_string: string; - std_deviation_bounds_as_string: { - upper: string; - lower: string; - upper_population: string; - lower_population: string; - upper_sampling: string; - lower_sampling: string; + } & SubAggregateOf; + filters: { + buckets: TAggregationContainer extends { filters: { filters: any[] } } + ? Array< + { + doc_count: number; + } & SubAggregateOf + > + : TAggregationContainer extends { filters: { filters: Record } } + ? { + [key in keyof TAggregationContainer['filters']['filters']]: { + doc_count: number; + } & SubAggregateOf; + } & (TAggregationContainer extends { + filters: { other_bucket_key: infer TOtherBucketKey }; + } + ? Record< + TOtherBucketKey & string, + { doc_count: number } & SubAggregateOf + > + : unknown) & + (TAggregationContainer extends { filters: { other_bucket: true } } + ? { + _other: { doc_count: number } & SubAggregateOf< + TAggregationContainer, + TDocument + >; + } + : unknown) + : unknown; + }; + geo_bounds: { + top_left: { + lat: number | null; + lon: number | null; }; - } - | {} - ); - extended_stats_bucket: { - count: number; - min: number | null; - max: number | null; - avg: number | null; - sum: number | null; - sum_of_squares: number | null; - variance: number | null; - variance_population: number | null; - variance_sampling: number | null; - std_deviation: number | null; - std_deviation_population: number | null; - std_deviation_sampling: number | null; - std_deviation_bounds: { - upper: number | null; - lower: number | null; - upper_population: number | null; - lower_population: number | null; - upper_sampling: number | null; - lower_sampling: number | null; - }; - }; - filter: { - doc_count: number; - } & SubAggregateOf; - filters: { - buckets: TAggregationContainer extends { filters: { filters: any[] } } - ? Array< + bottom_right: { + lat: number | null; + lon: number | null; + }; + }; + geo_centroid: { + count: number; + location: { + lat: number; + lon: number; + }; + }; + geo_distance: MaybeKeyed< + TAggregationContainer, + { + from: number; + to?: number; + doc_count: number; + } & SubAggregateOf + >; + geo_hash: { + buckets: Array< { doc_count: number; + key: string; } & SubAggregateOf - > - : TAggregationContainer extends { filters: { filters: Record } } - ? { - [key in keyof TAggregationContainer['filters']['filters']]: { + >; + }; + geotile_grid: { + buckets: Array< + { doc_count: number; - } & SubAggregateOf; - } & (TAggregationContainer extends { filters: { other_bucket_key: infer TOtherBucketKey } } - ? Record< - TOtherBucketKey & string, - { doc_count: number } & SubAggregateOf - > - : unknown) & - (TAggregationContainer extends { filters: { other_bucket: true } } - ? { _other: { doc_count: number } & SubAggregateOf } - : unknown) - : unknown; - }; - geo_bounds: { - top_left: { - lat: number | null; - lon: number | null; - }; - bottom_right: { - lat: number | null; - lon: number | null; - }; - }; - geo_centroid: { - count: number; - location: { - lat: number; - lon: number; - }; - }; - geo_distance: MaybeKeyed< - TAggregationContainer, - { - from: number; - to?: number; - doc_count: number; - } & SubAggregateOf - >; - geo_hash: { - buckets: Array< - { + key: string; + } & SubAggregateOf + >; + }; + global: { doc_count: number; - key: string; - } & SubAggregateOf - >; - }; - geotile_grid: { - buckets: Array< - { + } & SubAggregateOf; + histogram: MaybeKeyed< + TAggregationContainer, + { + key: number; + doc_count: number; + } & SubAggregateOf + >; + ip_range: MaybeKeyed< + TAggregationContainer, + { + key: string; + from?: string; + to?: string; + doc_count: number; + }, + TAggregationContainer extends { ip_range: { ranges: Array } } + ? TRangeType extends { key: infer TKeys } + ? TKeys + : string + : string + >; + inference: { + value: number; + prediction_probability: number; + prediction_score: number; + }; + max: { + value: number | null; + value_as_string?: string; + }; + max_bucket: { + value: number | null; + }; + min: { + value: number | null; + value_as_string?: string; + }; + min_bucket: { + value: number | null; + }; + median_absolute_deviation: { + value: number | null; + }; + moving_avg: + | { + value: number | null; + } + | undefined; + moving_fn: { + value: number | null; + }; + moving_percentiles: TAggregationContainer extends Record + ? Array<{ key: number; value: number | null }> + : Record | undefined; + missing: { doc_count: number; - key: string; - } & SubAggregateOf - >; - }; - global: { - doc_count: number; - } & SubAggregateOf; - histogram: MaybeKeyed< - TAggregationContainer, - { - key: number; - doc_count: number; - } & SubAggregateOf - >; - ip_range: MaybeKeyed< - TAggregationContainer, - { - key: string; - from?: string; - to?: string; - doc_count: number; - }, - TAggregationContainer extends { ip_range: { ranges: Array } } - ? TRangeType extends { key: infer TKeys } - ? TKeys - : string - : string - >; - inference: { - value: number; - prediction_probability: number; - prediction_score: number; - }; - max: { - value: number | null; - value_as_string?: string; - }; - max_bucket: { - value: number | null; - }; - min: { - value: number | null; - value_as_string?: string; - }; - min_bucket: { - value: number | null; - }; - median_absolute_deviation: { - value: number | null; - }; - moving_avg: - | { + } & SubAggregateOf; + multi_terms: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: Array< + { + doc_count: number; + key: string[]; + } & SubAggregateOf + >; + }; + nested: { + doc_count: number; + } & SubAggregateOf; + normalize: { value: number | null; - } - | undefined; - moving_fn: { - value: number | null; - }; - moving_percentiles: TAggregationContainer extends Record - ? Array<{ key: number; value: number | null }> - : Record | undefined; - missing: { - doc_count: number; - } & SubAggregateOf; - multi_terms: { - doc_count_error_upper_bound: number; - sum_other_doc_count: number; - buckets: Array< - { + // TODO: should be perhaps based on input? ie when `format` is specified + value_as_string?: string; + }; + parent: { doc_count: number; - key: string[]; - } & SubAggregateOf - >; - }; - nested: { - doc_count: number; - } & SubAggregateOf; - normalize: { - value: number | null; - // TODO: should be perhaps based on input? ie when `format` is specified - value_as_string?: string; - }; - parent: { - doc_count: number; - } & SubAggregateOf; - percentiles: { - values: TAggregationContainer extends Record - ? Array<{ key: number; value: number | null }> - : Record; - }; - percentile_ranks: { - values: TAggregationContainer extends Record - ? Array<{ key: number; value: number | null }> - : Record; - }; - percentiles_bucket: { - values: TAggregationContainer extends Record - ? Array<{ key: number; value: number | null }> - : Record; - }; - range: MaybeKeyed< - TAggregationContainer, - { - key: string; - from?: number; - from_as_string?: string; - to?: number; - to_as_string?: string; - doc_count: number; - }, - TAggregationContainer extends { range: { ranges: Array } } - ? TRangeType extends { key: infer TKeys } - ? TKeys - : string - : string - >; - rare_terms: Array< - { - key: string | number; - doc_count: number; - } & SubAggregateOf - >; - rate: { - value: number | null; - }; - reverse_nested: { - doc_count: number; - } & SubAggregateOf; - sampler: { - doc_count: number; - } & SubAggregateOf; - scripted_metric: { - value: unknown; - }; - serial_diff: { - value: number | null; - // TODO: should be perhaps based on input? ie when `format` is specified - value_as_string?: string; - }; - significant_terms: { - doc_count: number; - bg_count: number; - buckets: Array< - { - key: string | number; - score: number; + } & SubAggregateOf; + percentiles: { + values: TAggregationContainer extends Record + ? Array<{ key: number; value: number | null }> + : Record; + }; + percentile_ranks: { + values: TAggregationContainer extends Record + ? Array<{ key: number; value: number | null }> + : Record; + }; + percentiles_bucket: { + values: TAggregationContainer extends Record + ? Array<{ key: number; value: number | null }> + : Record; + }; + range: MaybeKeyed< + TAggregationContainer, + { + key: string; + from?: number; + from_as_string?: string; + to?: number; + to_as_string?: string; + doc_count: number; + }, + TAggregationContainer extends { range: { ranges: Array } } + ? TRangeType extends { key: infer TKeys } + ? TKeys + : string + : string + >; + rare_terms: Array< + { + key: string | number; + doc_count: number; + } & SubAggregateOf + >; + rate: { + value: number | null; + }; + reverse_nested: { + doc_count: number; + } & SubAggregateOf; + sampler: { + doc_count: number; + } & SubAggregateOf; + scripted_metric: { + value: unknown; + }; + serial_diff: { + value: number | null; + // TODO: should be perhaps based on input? ie when `format` is specified + value_as_string?: string; + }; + significant_terms: { doc_count: number; bg_count: number; - } & SubAggregateOf - >; - }; - significant_text: { - doc_count: number; - buckets: Array<{ - key: string; - doc_count: number; - score: number; - bg_count: number; - }>; - }; - stats: { - count: number; - min: number | null; - max: number | null; - avg: number | null; - sum: number; - } & ( - | { - min_as_string: string; - max_as_string: string; - avg_as_string: string; - sum_as_string: string; - } - | {} - ); - stats_bucket: { - count: number; - min: number | null; - max: number | null; - avg: number | null; - sum: number; - }; - string_stats: { - count: number; - min_length: number | null; - max_length: number | null; - avg_length: number | null; - entropy: number | null; - distribution: Record; - }; - sum: { - value: number | null; - value_as_string?: string; - }; - sum_bucket: { - value: number | null; - }; - terms: { - doc_count_error_upper_bound: number; - sum_other_doc_count: number; - buckets: Array< - { + buckets: Array< + { + key: string | number; + score: number; + doc_count: number; + bg_count: number; + } & SubAggregateOf + >; + }; + significant_text: { doc_count: number; - key: string | number; - } & SubAggregateOf - >; - }; - top_hits: { - hits: { - total: { + buckets: Array<{ + key: string; + doc_count: number; + score: number; + bg_count: number; + }>; + }; + stats: { + count: number; + min: number | null; + max: number | null; + avg: number | null; + sum: number; + } & ( + | { + min_as_string: string; + max_as_string: string; + avg_as_string: string; + sum_as_string: string; + } + | {} + ); + stats_bucket: { + count: number; + min: number | null; + max: number | null; + avg: number | null; + sum: number; + }; + string_stats: { + count: number; + min_length: number | null; + max_length: number | null; + avg_length: number | null; + entropy: number | null; + distribution: Record; + }; + sum: { + value: number | null; + value_as_string?: string; + }; + sum_bucket: { + value: number | null; + }; + terms: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: Array< + { + doc_count: number; + key: string | number; + } & SubAggregateOf + >; + }; + top_hits: { + hits: { + total: { + value: number; + relation: 'eq' | 'gte'; + }; + max_score: number | null; + hits: TAggregationContainer extends { top_hits: estypes.AggregationsTopHitsAggregation } + ? HitsOf + : estypes.SearchHitsMetadata; + }; + }; + top_metrics: { + top: Array<{ + sort: number[] | string[]; + metrics: Record, string | number | null>; + }>; + }; + weighted_avg: { value: number | null }; + value_count: { value: number; - relation: 'eq' | 'gte'; }; - max_score: number | null; - hits: TAggregationContainer extends { top_hits: estypes.AggregationsTopHitsAggregation } - ? HitsOf - : estypes.SearchHitsMetadata; - }; - }; - top_metrics: { - top: Array<{ - sort: number[] | string[]; - metrics: Record, string | number | null>; - }>; - }; - weighted_avg: { value: number | null }; - value_count: { - value: number; - }; - // t_test: {} not defined -})[ValidAggregationKeysOf & AggregationTypeName]; + // t_test: {} not defined + }, + Exclude, 'aggs' | 'aggregations'> & string + > +>; type AggregateOfMap = { - [TAggregationName in keyof TAggregationMap]: Required[TAggregationName] extends estypes.AggregationsAggregationContainer + [TAggregationName in keyof TAggregationMap]: Required[TAggregationName] extends AggregationsAggregationContainer ? AggregateOf : never; // using never means we effectively ignore optional keys, using {} creates a union type of { ... } | {} }; diff --git a/src/dev/build/lib/fs.ts b/src/dev/build/lib/fs.ts index a47fb64c5e563..47b98c9cc398a 100644 --- a/src/dev/build/lib/fs.ts +++ b/src/dev/build/lib/fs.ts @@ -117,10 +117,10 @@ export async function deleteEmptyFolders( // Delete empty is used to gather all the empty folders and // then we use del to actually delete them - const emptyFoldersList = await deleteEmpty(rootFolderPath, { + const emptyFoldersList = (await deleteEmpty(rootFolderPath, { // @ts-expect-error DT package has incorrect types https://github.com/jonschlinkert/delete-empty/blob/6ae34547663e6845c3c98b184c606fa90ef79c0a/index.js#L160 dryRun: true, - }); + })) as unknown as string[]; // DT package has incorrect types const foldersToDelete = emptyFoldersList.filter((folderToDelete) => { return !foldersToKeep.some((folderToKeep) => folderToDelete.includes(folderToKeep)); diff --git a/src/dev/build/lib/integration_tests/version_info.test.ts b/src/dev/build/lib/integration_tests/version_info.test.ts index e7a3a04c04734..9385de6e00a4f 100644 --- a/src/dev/build/lib/integration_tests/version_info.test.ts +++ b/src/dev/build/lib/integration_tests/version_info.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { kibanaPackageJson as pkg } from '@kbn/dev-utils'; +import { kibanaPackageJson as pkg } from '@kbn/utils'; import { getVersionInfo } from '../version_info'; diff --git a/src/dev/build/tasks/build_kibana_example_plugins.ts b/src/dev/build/tasks/build_kibana_example_plugins.ts index 7eb696ffdd3b2..93ebf41d259e7 100644 --- a/src/dev/build/tasks/build_kibana_example_plugins.ts +++ b/src/dev/build/tasks/build_kibana_example_plugins.ts @@ -13,17 +13,23 @@ import { exec, mkdirp, copyAll, Task } from '../lib'; export const BuildKibanaExamplePlugins: Task = { description: 'Building distributable versions of Kibana example plugins', - async run(config, log, build) { - const examplesDir = Path.resolve(REPO_ROOT, 'examples'); + async run(config, log) { const args = [ - '../../scripts/plugin_helpers', + Path.resolve(REPO_ROOT, 'scripts/plugin_helpers'), 'build', `--kibana-version=${config.getBuildVersion()}`, ]; - const folders = Fs.readdirSync(examplesDir, { withFileTypes: true }) - .filter((f) => f.isDirectory()) - .map((f) => Path.resolve(REPO_ROOT, 'examples', f.name)); + const getExampleFolders = (dir: string) => { + return Fs.readdirSync(dir, { withFileTypes: true }) + .filter((f) => f.isDirectory()) + .map((f) => Path.resolve(dir, f.name)); + }; + + const folders = [ + ...getExampleFolders(Path.resolve(REPO_ROOT, 'examples')), + ...getExampleFolders(Path.resolve(REPO_ROOT, 'x-pack/examples')), + ]; for (const examplePlugin of folders) { try { @@ -40,8 +46,8 @@ export const BuildKibanaExamplePlugins: Task = { const pluginsDir = config.resolveFromTarget('example_plugins'); await mkdirp(pluginsDir); - await copyAll(examplesDir, pluginsDir, { - select: ['*/build/*.zip'], + await copyAll(REPO_ROOT, pluginsDir, { + select: ['examples/*/build/*.zip', 'x-pack/examples/*/build/*.zip'], }); }, }; diff --git a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts index 02b469820f900..cc1ffb5f3e301 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts @@ -10,7 +10,8 @@ import { resolve } from 'path'; import { readFileSync } from 'fs'; import { copyFile } from 'fs/promises'; -import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import Mustache from 'mustache'; import { compressTar, copyAll, mkdirp, write, Config } from '../../../lib'; 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 895c42ad5f47d..a7d8fe684ef95 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 @@ -354,6 +354,7 @@ kibana_vars=( xpack.security.showInsecureClusterWarning xpack.securitySolution.alertMergeStrategy xpack.securitySolution.alertIgnoreFields + xpack.securitySolution.maxExceptionsImportSize xpack.securitySolution.maxRuleImportExportSize xpack.securitySolution.maxRuleImportPayloadBytes xpack.securitySolution.maxTimelineImportExportSize diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 6a192baed3fa3..085b4393caa66 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -10,7 +10,8 @@ import { access, link, unlink, chmod } from 'fs'; import { resolve, basename } from 'path'; import { promisify } from 'util'; -import { ToolingLog, kibanaPackageJson } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { kibanaPackageJson } from '@kbn/utils'; import { write, copyAll, mkdirp, exec, Config, Build } from '../../../lib'; import * as dockerTemplates from './templates'; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile index b1d9fafffab57..90a622e64efe4 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile @@ -16,7 +16,7 @@ RUN {{packageManager}} install -y findutils tar gzip {{/ubi}} {{#usePublicArtifact}} -RUN cd /opt && \ +RUN cd /tmp && \ curl --retry 8 -s -L \ --output kibana.tar.gz \ https://artifacts.elastic.co/downloads/kibana/{{artifactPrefix}}-$(arch).tar.gz && \ diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile index dbdace85eda01..e9a6ef3539692 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile @@ -2,9 +2,9 @@ # Build stage 0 # Extract Kibana and make various file manipulations. ################################################################################ -ARG BASE_REGISTRY=registry1.dsop.io +ARG BASE_REGISTRY=registry1.dso.mil ARG BASE_IMAGE=redhat/ubi/ubi8 -ARG BASE_TAG=8.4 +ARG BASE_TAG=8.5 FROM ${BASE_REGISTRY}/${BASE_IMAGE}:${BASE_TAG} as prep_files diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yaml b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yaml index 24614039e5eb7..1c7926c2fcbc2 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yaml +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yaml @@ -14,7 +14,7 @@ tags: # Build args passed to Dockerfile ARGs args: BASE_IMAGE: 'redhat/ubi/ubi8' - BASE_TAG: '8.4' + BASE_TAG: '8.5' # Docker image labels labels: @@ -59,4 +59,4 @@ maintainers: - email: "yalabe.dukuly@anchore.com" name: "Yalabe Dukuly" username: "yalabe.dukuly" - cht_member: true \ No newline at end of file + cht_member: true diff --git a/src/dev/chromium_version.ts b/src/dev/chromium_version.ts index 410fcc72fbc0f..1f55330a92bb6 100644 --- a/src/dev/chromium_version.ts +++ b/src/dev/chromium_version.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { run, REPO_ROOT, ToolingLog } from '@kbn/dev-utils'; +import { run, ToolingLog } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import chalk from 'chalk'; import cheerio from 'cheerio'; import fs from 'fs'; diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js b/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js index 57467d84f1f61..40d36ed46ea34 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js +++ b/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js @@ -7,7 +7,8 @@ */ import { enumeratePatterns } from '../team_assignment/enumerate_patterns'; -import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; const log = new ToolingLog({ level: 'info', diff --git a/src/dev/code_coverage/ingest_coverage/team_assignment/index.js b/src/dev/code_coverage/ingest_coverage/team_assignment/index.js index 0e341a3aac1dc..a38c4ee50b40a 100644 --- a/src/dev/code_coverage/ingest_coverage/team_assignment/index.js +++ b/src/dev/code_coverage/ingest_coverage/team_assignment/index.js @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { run, createFlagError, REPO_ROOT } from '@kbn/dev-utils'; +import { run, createFlagError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { parse } from './parse_owners'; import { flush } from './flush'; import { enumeratePatterns } from './enumerate_patterns'; diff --git a/src/dev/ensure_all_tests_in_ci_group.ts b/src/dev/ensure_all_tests_in_ci_group.ts index aeccefae05d2c..a2d9729d3352b 100644 --- a/src/dev/ensure_all_tests_in_ci_group.ts +++ b/src/dev/ensure_all_tests_in_ci_group.ts @@ -12,7 +12,8 @@ import Fs from 'fs/promises'; import execa from 'execa'; import { safeLoad } from 'js-yaml'; -import { run, REPO_ROOT } from '@kbn/dev-utils'; +import { run } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { schema } from '@kbn/config-schema'; const RELATIVE_JOBS_YAML_PATH = '.ci/ci_groups.yml'; diff --git a/src/dev/eslint/run_eslint_with_types.ts b/src/dev/eslint/run_eslint_with_types.ts index 750011dea1031..0f2a10d07d681 100644 --- a/src/dev/eslint/run_eslint_with_types.ts +++ b/src/dev/eslint/run_eslint_with_types.ts @@ -14,7 +14,8 @@ import execa from 'execa'; import * as Rx from 'rxjs'; import { mergeMap, reduce } from 'rxjs/operators'; import { supportsColor } from 'chalk'; -import { REPO_ROOT, run, createFailError } from '@kbn/dev-utils'; +import { run, createFailError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { lastValueFrom } from '@kbn/std'; import { PROJECTS } from '../typescript/projects'; diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 52b1f816090df..9674694c0d655 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -76,6 +76,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@8.0.0': ['Elastic License 2.0'], - '@elastic/eui@41.0.0': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@41.2.3': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/dev/plugin_discovery/find_plugins.ts b/src/dev/plugin_discovery/find_plugins.ts index f1725f34d1f8e..53a53bc08e15b 100644 --- a/src/dev/plugin_discovery/find_plugins.ts +++ b/src/dev/plugin_discovery/find_plugins.ts @@ -8,11 +8,9 @@ import Path from 'path'; import { getPluginSearchPaths } from '@kbn/config'; -import { - KibanaPlatformPlugin, - REPO_ROOT, - simpleKibanaPlatformPluginDiscovery, -} from '@kbn/dev-utils'; +import { KibanaPlatformPlugin, simpleKibanaPlatformPluginDiscovery } from '@kbn/dev-utils'; + +import { REPO_ROOT } from '@kbn/utils'; export interface SearchOptions { oss: boolean; diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index e3d9688e60962..e885180cdb803 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -42,6 +42,9 @@ export const IGNORE_FILE_GLOBS = [ 'test/package/Vagrantfile', '**/test/**/fixtures/**/*', + // Required to match the name in the docs.elastic.dev repo. + 'nav-kibana-dev.docnav.json', + // filename must match language code which requires capital letters '**/translations/*.json', diff --git a/src/dev/run_build_docs_cli.ts b/src/dev/run_build_docs_cli.ts index aad524b4437d3..8ee75912c1a7e 100644 --- a/src/dev/run_build_docs_cli.ts +++ b/src/dev/run_build_docs_cli.ts @@ -9,7 +9,8 @@ import Path from 'path'; import dedent from 'dedent'; -import { run, REPO_ROOT, createFailError } from '@kbn/dev-utils'; +import { run, createFailError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; const DEFAULT_DOC_REPO_PATH = Path.resolve(REPO_ROOT, '..', 'docs'); diff --git a/src/dev/run_find_plugins_with_circular_deps.ts b/src/dev/run_find_plugins_with_circular_deps.ts index f7974b464fcaf..f9ee7bd84c54f 100644 --- a/src/dev/run_find_plugins_with_circular_deps.ts +++ b/src/dev/run_find_plugins_with_circular_deps.ts @@ -10,7 +10,8 @@ import dedent from 'dedent'; import { parseDependencyTree, parseCircular, prettyCircular } from 'dpdm'; import { relative } from 'path'; import { getPluginSearchPaths } from '@kbn/config'; -import { REPO_ROOT, run } from '@kbn/dev-utils'; +import { run } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; interface Options { debug?: boolean; diff --git a/src/dev/run_precommit_hook.js b/src/dev/run_precommit_hook.js index a7bd0a9f57f6e..dfa3a94426bb2 100644 --- a/src/dev/run_precommit_hook.js +++ b/src/dev/run_precommit_hook.js @@ -8,7 +8,8 @@ import SimpleGit from 'simple-git/promise'; -import { run, combineErrors, createFlagError, REPO_ROOT } from '@kbn/dev-utils'; +import { run, combineErrors, createFlagError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import * as Eslint from './eslint'; import * as Stylelint from './stylelint'; import { getFilesForCommit, checkFileCasing } from './precommit_hook'; diff --git a/src/dev/typescript/build_ts_refs.ts b/src/dev/typescript/build_ts_refs.ts index aaa8c0d12fa4d..f3896cf676e27 100644 --- a/src/dev/typescript/build_ts_refs.ts +++ b/src/dev/typescript/build_ts_refs.ts @@ -8,7 +8,8 @@ import Path from 'path'; -import { ToolingLog, REPO_ROOT, ProcRunner } from '@kbn/dev-utils'; +import { ToolingLog, ProcRunner } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { ROOT_REFS_CONFIG_PATH } from './root_refs_config'; import { Project } from './project'; diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts index c68424c2a98f7..09866315fc8dd 100644 --- a/src/dev/typescript/build_ts_refs_cli.ts +++ b/src/dev/typescript/build_ts_refs_cli.ts @@ -8,7 +8,8 @@ import Path from 'path'; -import { run, REPO_ROOT, createFlagError } from '@kbn/dev-utils'; +import { run, createFlagError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import del from 'del'; import { RefOutputCache } from './ref_output_cache'; diff --git a/src/dev/typescript/ref_output_cache/ref_output_cache.ts b/src/dev/typescript/ref_output_cache/ref_output_cache.ts index b7e641ceb33d5..32b08ec1ba0df 100644 --- a/src/dev/typescript/ref_output_cache/ref_output_cache.ts +++ b/src/dev/typescript/ref_output_cache/ref_output_cache.ts @@ -9,7 +9,8 @@ import Path from 'path'; import Fs from 'fs/promises'; -import { ToolingLog, kibanaPackageJson, extract } from '@kbn/dev-utils'; +import { ToolingLog, extract } from '@kbn/dev-utils'; +import { kibanaPackageJson } from '@kbn/utils'; import del from 'del'; import tempy from 'tempy'; diff --git a/src/dev/typescript/root_refs_config.ts b/src/dev/typescript/root_refs_config.ts index f4aa88f1ea6b2..e20b1ab46cd82 100644 --- a/src/dev/typescript/root_refs_config.ts +++ b/src/dev/typescript/root_refs_config.ts @@ -10,7 +10,8 @@ import Path from 'path'; import Fs from 'fs/promises'; import dedent from 'dedent'; -import { REPO_ROOT, ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import normalize from 'normalize-path'; import { PROJECTS } from './projects'; diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index 7562b6a660193..033d5e9da9eab 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -5,7 +5,7 @@ "ui": true, "requiredPlugins": ["management"], "optionalPlugins": ["home", "usageCollection"], - "requiredBundles": ["kibanaReact", "kibanaUtils", "home", "esUiShared"], + "requiredBundles": ["kibanaReact", "kibanaUtils", "home"], "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index c0decf516fbad..e0966d70aeb98 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -33,6 +33,7 @@ import { getAriaName, toEditableConfig, fieldSorter, DEFAULT_CATEGORY } from './ import { FieldSetting, SettingsChanges } from './types'; import { parseErrorMsg } from './components/search/search'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; export const QUERY = 'query'; @@ -259,21 +260,23 @@ export class AdvancedSettings extends Component -
+ + + - @@ -1906,30 +1893,17 @@ exports[`Field for json setting should render as read only with help text if ove
-
@@ -1989,30 +1963,17 @@ exports[`Field for json setting should render custom setting icon if it is custo
-
@@ -2103,30 +2064,17 @@ exports[`Field for json setting should render default value if there is no user
-
@@ -2192,35 +2140,22 @@ exports[`Field for json setting should render unsaved value if there are unsaved
-
@@ -2318,30 +2253,17 @@ exports[`Field for json setting should render user value if there is user value
-
@@ -2390,30 +2312,17 @@ exports[`Field for markdown setting should render as read only if saving is disa
-
@@ -2494,30 +2403,17 @@ exports[`Field for markdown setting should render as read only with help text if
-
@@ -2577,30 +2473,17 @@ exports[`Field for markdown setting should render custom setting icon if it is c
-
@@ -2649,30 +2532,17 @@ exports[`Field for markdown setting should render default value if there is no u
-
@@ -2738,31 +2608,18 @@ exports[`Field for markdown setting should render unsaved value if there are uns
-
@@ -2857,30 +2714,17 @@ exports[`Field for markdown setting should render user value if there is user va
-
diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx index 7047959522427..b77a687b50cd9 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx @@ -17,8 +17,9 @@ import { notificationServiceMock, docLinksServiceMock } from '../../../../../../ import { findTestSubject } from '@elastic/eui/lib/test'; import { Field, getEditableValue } from './field'; -jest.mock('brace/theme/textmate', () => 'brace/theme/textmate'); -jest.mock('brace/mode/markdown', () => 'brace/mode/markdown'); +jest.mock('../../../../../kibana_react/public/ui_settings/use_ui_setting', () => ({ + useUiSetting: jest.fn(), +})); const defaults = { requiresPageReload: false, diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index 586609fa1bf64..e43f30e52ee74 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -8,10 +8,6 @@ import React, { PureComponent, Fragment } from 'react'; import classNames from 'classnames'; -import 'react-ace'; -import 'brace/theme/textmate'; -import 'brace/mode/markdown'; -import 'brace/mode/json'; import { EuiBadge, @@ -36,10 +32,10 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { FieldCodeEditor } from './field_code_editor'; import { FieldSetting, FieldState } from '../../types'; import { isDefaultValue } from '../../lib'; import { UiSettingsType, DocLinksStart, ToastsStart } from '../../../../../../core/public'; -import { EuiCodeEditor } from '../../../../../es_ui_shared/public'; interface FieldProps { setting: FieldSetting; @@ -130,7 +126,7 @@ export class Field extends PureComponent { switch (type) { case 'json': const isJsonArray = Array.isArray(JSON.parse((defVal as string) || '{}')); - newUnsavedValue = value.trim() || (isJsonArray ? '[]' : '{}'); + newUnsavedValue = value || (isJsonArray ? '[]' : '{}'); try { JSON.parse(newUnsavedValue); } catch (e) { @@ -291,26 +287,13 @@ export class Field extends PureComponent { case 'json': return (
-
); diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field_code_editor.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field_code_editor.tsx new file mode 100644 index 0000000000000..5ba1c55e67ec8 --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/components/field/field_code_editor.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { monaco, XJsonLang } from '@kbn/monaco'; +import { CodeEditor, MarkdownLang } from '../../../../../../../src/plugins/kibana_react/public'; + +interface FieldCodeEditorProps { + value: string; + onChange: (value: string) => void; + type: 'markdown' | 'json'; + isReadOnly: boolean; + a11yProps: Record; + name: string; +} + +const MIN_DEFAULT_LINES_COUNT = 6; +const MAX_DEFAULT_LINES_COUNT = 30; + +export const FieldCodeEditor = ({ + value, + onChange, + type, + isReadOnly, + a11yProps, + name, +}: FieldCodeEditorProps) => { + // setting editor height based on lines height and count to stretch and fit its content + const setEditorCalculatedHeight = useCallback( + (editor: monaco.editor.IStandaloneCodeEditor) => { + const editorElement = editor.getDomNode(); + + if (!editorElement) { + return; + } + + const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); + let lineCount = editor.getModel()?.getLineCount() || MIN_DEFAULT_LINES_COUNT; + if (lineCount < MIN_DEFAULT_LINES_COUNT) { + lineCount = MIN_DEFAULT_LINES_COUNT; + } else if (lineCount > MAX_DEFAULT_LINES_COUNT) { + lineCount = MAX_DEFAULT_LINES_COUNT; + } + const height = lineHeight * lineCount; + + editorElement.id = name; + editorElement.style.height = `${height}px`; + editor.layout(); + }, + [name] + ); + + const trimEditorBlankLines = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { + const editorModel = editor.getModel(); + + if (!editorModel) { + return; + } + const trimmedValue = editorModel.getValue().trim(); + editorModel.setValue(trimmedValue); + }, []); + + const editorDidMount = useCallback( + (editor) => { + setEditorCalculatedHeight(editor); + + editor.onDidChangeModelContent(() => { + setEditorCalculatedHeight(editor); + }); + + editor.onDidBlurEditorWidget(() => { + trimEditorBlankLines(editor); + }); + }, + [setEditorCalculatedHeight, trimEditorBlankLines] + ); + + return ( + + ); +}; diff --git a/src/plugins/console/public/application/components/editor_example.tsx b/src/plugins/console/public/application/components/editor_example.tsx index 577f32fa912fb..21e3ab0c7d274 100644 --- a/src/plugins/console/public/application/components/editor_example.tsx +++ b/src/plugins/console/public/application/components/editor_example.tsx @@ -8,8 +8,10 @@ import { EuiScreenReaderOnly } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useEffect } from 'react'; -import { createReadOnlyAceEditor } from '../models/legacy_core_editor'; +import React, { useEffect, useRef } from 'react'; +import { createReadOnlyAceEditor, CustomAceEditor } from '../models/sense_editor'; +// @ts-ignore +import { Mode } from '../models/legacy_core_editor/mode/input'; interface EditorExampleProps { panel: string; @@ -27,21 +29,33 @@ GET index/_doc/1 `; export function EditorExample(props: EditorExampleProps) { - const elemId = `help-example-${props.panel}`; const inputId = `help-example-${props.panel}-input`; + const wrapperDivRef = useRef(null); + const editorRef = useRef(); useEffect(() => { - const el = document.getElementById(elemId)!; - el.textContent = exampleText.trim(); - const editor = createReadOnlyAceEditor(el); - const textarea = el.querySelector('textarea')!; - textarea.setAttribute('id', inputId); - textarea.setAttribute('readonly', 'true'); + if (wrapperDivRef.current) { + editorRef.current = createReadOnlyAceEditor(wrapperDivRef.current); + + const editor = editorRef.current; + editor.update(exampleText.trim()); + editor.session.setMode(new Mode()); + editor.session.setUseWorker(false); + editor.setHighlightActiveLine(false); + + const textareaElement = wrapperDivRef.current.querySelector('textarea'); + if (textareaElement) { + textareaElement.setAttribute('id', inputId); + textareaElement.setAttribute('readonly', 'true'); + } + } return () => { - editor.destroy(); + if (editorRef.current) { + editorRef.current.destroy(); + } }; - }, [elemId, inputId]); + }, [inputId]); return ( <> @@ -52,7 +66,7 @@ export function EditorExample(props: EditorExampleProps) { })}
-
+
); } diff --git a/src/plugins/console/public/application/contexts/services_context.mock.ts b/src/plugins/console/public/application/contexts/services_context.mock.ts index c19413bdd0413..90a5d9ddce010 100644 --- a/src/plugins/console/public/application/contexts/services_context.mock.ts +++ b/src/plugins/console/public/application/contexts/services_context.mock.ts @@ -7,7 +7,7 @@ */ import { notificationServiceMock } from '../../../../../core/public/mocks'; -import { httpServiceMock } from '../../../../../core/public/mocks'; +import { httpServiceMock, themeServiceMock } from '../../../../../core/public/mocks'; import type { ObjectStorageClient } from '../../../common/types'; import { HistoryMock } from '../../services/history.mock'; @@ -35,6 +35,7 @@ export const serviceContextMock = { objectStorageClient: {} as unknown as ObjectStorageClient, }, docLinkVersion: 'NA', + theme$: themeServiceMock.create().start().theme$, }; }, }; diff --git a/src/plugins/console/public/application/contexts/services_context.tsx b/src/plugins/console/public/application/contexts/services_context.tsx index 53c021d4d0982..5912de0375590 100644 --- a/src/plugins/console/public/application/contexts/services_context.tsx +++ b/src/plugins/console/public/application/contexts/services_context.tsx @@ -7,7 +7,9 @@ */ import React, { createContext, useContext, useEffect } from 'react'; -import { NotificationsSetup } from 'kibana/public'; +import { Observable } from 'rxjs'; +import { NotificationsSetup, CoreTheme } from 'kibana/public'; + import { History, Settings, Storage } from '../../services'; import { ObjectStorageClient } from '../../../common/types'; import { MetricsTracker } from '../../types'; @@ -26,6 +28,7 @@ interface ContextServices { export interface ContextValue { services: ContextServices; docLinkVersion: string; + theme$: Observable; } interface ContextProps { diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts index d025760c19d0a..81aa571b45a20 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts @@ -8,20 +8,21 @@ import { i18n } from '@kbn/i18n'; import { useCallback } from 'react'; + +import { toMountPoint } from '../../../shared_imports'; import { isQuotaExceededError } from '../../../services/history'; +// @ts-ignore +import { retrieveAutoCompleteInfo } from '../../../lib/mappings/mappings'; import { instance as registry } from '../../contexts/editor_context/editor_registry'; import { useRequestActionContext, useServicesContext } from '../../contexts'; +import { StorageQuotaError } from '../../components/storage_quota_error'; import { sendRequestToES } from './send_request_to_es'; import { track } from './track'; -import { toMountPoint } from '../../../../../kibana_react/public'; - -// @ts-ignore -import { retrieveAutoCompleteInfo } from '../../../lib/mappings/mappings'; -import { StorageQuotaError } from '../../components/storage_quota_error'; export const useSendCurrentRequestToES = () => { const { services: { history, settings, notifications, trackUiMetric }, + theme$, } = useServicesContext(); const dispatch = useRequestActionContext(); @@ -83,7 +84,8 @@ export const useSendCurrentRequestToES = () => { settings.setHistoryDisabled(true); notifications.toasts.remove(toast); }, - }) + }), + { theme$ } ), }); } else { @@ -127,5 +129,5 @@ export const useSendCurrentRequestToES = () => { }); } } - }, [dispatch, settings, history, notifications, trackUiMetric]); + }, [dispatch, settings, history, notifications, trackUiMetric, theme$]); }; diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx index 0b41095f8cc19..719975874cd44 100644 --- a/src/plugins/console/public/application/index.tsx +++ b/src/plugins/console/public/application/index.tsx @@ -8,13 +8,16 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { HttpSetup, NotificationsSetup, I18nStart } from 'src/core/public'; -import { ServicesContextProvider, EditorContextProvider, RequestContextProvider } from './contexts'; -import { Main } from './containers'; +import { Observable } from 'rxjs'; +import { HttpSetup, NotificationsSetup, I18nStart, CoreTheme } from 'src/core/public'; + +import { UsageCollectionSetup } from '../../../usage_collection/public'; +import { KibanaThemeProvider } from '../shared_imports'; import { createStorage, createHistory, createSettings } from '../services'; -import * as localStorageObjectClient from '../lib/local_storage_object_client'; import { createUsageTracker } from '../services/tracker'; -import { UsageCollectionSetup } from '../../../usage_collection/public'; +import * as localStorageObjectClient from '../lib/local_storage_object_client'; +import { Main } from './containers'; +import { ServicesContextProvider, EditorContextProvider, RequestContextProvider } from './contexts'; import { createApi, createEsHostService } from './lib'; export interface BootDependencies { @@ -24,6 +27,7 @@ export interface BootDependencies { notifications: NotificationsSetup; usageCollection?: UsageCollectionSetup; element: HTMLElement; + theme$: Observable; } export function renderApp({ @@ -33,6 +37,7 @@ export function renderApp({ usageCollection, element, http, + theme$, }: BootDependencies) { const trackUiMetric = createUsageTracker(usageCollection); trackUiMetric.load('opened_app'); @@ -49,26 +54,29 @@ export function renderApp({ render( - - - -
- - - + + + + +
+ + + + , element ); diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index d61769c23dfe0..f46f60b485d55 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -52,7 +52,7 @@ export class ConsoleUIPlugin implements Plugin { + mount: async ({ element, theme$ }) => { const [core] = await getStartServices(); const { @@ -69,6 +69,7 @@ export class ConsoleUIPlugin implements Plugin { // populated by a global rule }, }, + script_score: { + __template: { + script: {}, + query: {}, + }, + script: {}, + query: {}, + min_score: '', + boost: 1.0, + }, wrapper: { __template: { query: 'QUERY_BASE64_ENCODED', diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/search.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/search.json new file mode 100644 index 0000000000000..1028422b303f2 --- /dev/null +++ b/src/plugins/console/server/lib/spec_definitions/json/overrides/search.json @@ -0,0 +1,7 @@ +{ + "search": { + "url_params": { + "error_trace": true + } + } +} diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/snapshot.create_repository.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/snapshot.create_repository.json index c513292f2bd59..3559b8e3811c0 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/overrides/snapshot.create_repository.json +++ b/src/plugins/console/server/lib/spec_definitions/json/overrides/snapshot.create_repository.json @@ -9,7 +9,7 @@ "settings": { "__one_of": [{ "__condition": { - "lines_regex": "type[\"']\\s*:\\s*[\"']fs`" + "lines_regex": "type[\"']\\s*:\\s*[\"']fs" }, "__template": { "location": "path" diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 3e259d4e26179..36261fbe130a3 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -101,6 +101,7 @@ export class DashboardContainer extends Container void; public controlGroup?: ControlGroupContainer; + private domNode?: HTMLElement; public getPanelCount = () => { return Object.keys(this.getInput().panels).length; @@ -258,6 +259,10 @@ export class DashboardContainer extends Container @@ -275,6 +280,7 @@ export class DashboardContainer extends Container( original as unknown as DashboardDiffCommonFilters, newState as unknown as DashboardDiffCommonFilters, - ['viewMode', 'panels', 'options', 'savedQuery', 'expandedPanelId', 'controlGroupInput'], + [ + 'viewMode', + 'panels', + 'options', + 'fullScreenMode', + 'savedQuery', + 'expandedPanelId', + 'controlGroupInput', + ], true ); 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 effbf8ce980d7..44b1aec226fd6 100644 --- a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -86,6 +86,7 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { const factories = embeddable ? Array.from(embeddable.getEmbeddableFactories()).filter( ({ type, isEditable, canCreateNew, isContainerType }) => + // @ts-expect-error ts 4.5 upgrade isEditable() && !isContainerType && canCreateNew() && type !== 'visualization' ) : []; diff --git a/src/plugins/data/common/search/aggs/agg_types.ts b/src/plugins/data/common/search/aggs/agg_types.ts index dd930887f9d19..87496767a33b2 100644 --- a/src/plugins/data/common/search/aggs/agg_types.ts +++ b/src/plugins/data/common/search/aggs/agg_types.ts @@ -14,7 +14,6 @@ import * as metrics from './metrics'; import { BUCKET_TYPES, CalculateBoundsFn } from './buckets'; import { METRIC_TYPES } from './metrics'; -/** @internal */ export interface AggTypesDependencies { calculateBounds: CalculateBoundsFn; getConfig: (key: string) => T; @@ -62,6 +61,8 @@ export const getAggTypes = () => ({ { name: BUCKET_TYPES.SIGNIFICANT_TERMS, fn: buckets.getSignificantTermsBucketAgg }, { name: BUCKET_TYPES.GEOHASH_GRID, fn: buckets.getGeoHashBucketAgg }, { name: BUCKET_TYPES.GEOTILE_GRID, fn: buckets.getGeoTitleBucketAgg }, + { name: BUCKET_TYPES.SAMPLER, fn: buckets.getSamplerBucketAgg }, + { name: BUCKET_TYPES.DIVERSIFIED_SAMPLER, fn: buckets.getDiversifiedSamplerBucketAgg }, ], }); @@ -79,6 +80,8 @@ export const getAggTypesFunctions = () => [ buckets.aggDateHistogram, buckets.aggTerms, buckets.aggMultiTerms, + buckets.aggSampler, + buckets.aggDiversifiedSampler, metrics.aggAvg, metrics.aggBucketAvg, metrics.aggBucketMax, diff --git a/src/plugins/data/common/search/aggs/agg_types_registry.ts b/src/plugins/data/common/search/aggs/agg_types_registry.ts index 108b1eb379ddd..4e57b4db3fb50 100644 --- a/src/plugins/data/common/search/aggs/agg_types_registry.ts +++ b/src/plugins/data/common/search/aggs/agg_types_registry.ts @@ -16,8 +16,6 @@ export type AggTypesRegistrySetup = ReturnType; * real start contract we will need to return the initialized versions. * So we need to provide the correct typings so they can be overwritten * on client/server. - * - * @internal */ export interface AggTypesRegistryStart { get: (id: string) => BucketAggType | MetricAggType; diff --git a/src/plugins/data/common/search/aggs/aggs_service.test.ts b/src/plugins/data/common/search/aggs/aggs_service.test.ts index be3fbae26174a..571083c18156f 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.test.ts @@ -73,6 +73,8 @@ describe('Aggs service', () => { "significant_terms", "geohash_grid", "geotile_grid", + "sampler", + "diversified_sampler", "foo", ] `); @@ -122,6 +124,8 @@ describe('Aggs service', () => { "significant_terms", "geohash_grid", "geotile_grid", + "sampler", + "diversified_sampler", ] `); expect(bStart.types.getAll().metrics.map((t) => t(aggTypesDependencies).name)) diff --git a/src/plugins/data/common/search/aggs/aggs_service.ts b/src/plugins/data/common/search/aggs/aggs_service.ts index 86bda5019a496..58f65bb0cab44 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.ts @@ -32,12 +32,10 @@ export const aggsRequiredUiSettings = [ UI_SETTINGS.COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX, ]; -/** @internal */ export interface AggsCommonSetupDependencies { registerFunction: ExpressionsServiceSetup['registerFunction']; } -/** @internal */ export interface AggsCommonStartDependencies { getConfig: GetConfigFn; getIndexPattern(id: string): Promise; diff --git a/src/plugins/data/common/search/aggs/buckets/bucket_agg_types.ts b/src/plugins/data/common/search/aggs/buckets/bucket_agg_types.ts index 0c01bff90bfee..671266ef15997 100644 --- a/src/plugins/data/common/search/aggs/buckets/bucket_agg_types.ts +++ b/src/plugins/data/common/search/aggs/buckets/bucket_agg_types.ts @@ -19,4 +19,6 @@ export enum BUCKET_TYPES { GEOHASH_GRID = 'geohash_grid', GEOTILE_GRID = 'geotile_grid', DATE_HISTOGRAM = 'date_histogram', + SAMPLER = 'sampler', + DIVERSIFIED_SAMPLER = 'diversified_sampler', } diff --git a/src/plugins/data/common/search/aggs/buckets/diversified_sampler.ts b/src/plugins/data/common/search/aggs/buckets/diversified_sampler.ts new file mode 100644 index 0000000000000..31ebaa094c368 --- /dev/null +++ b/src/plugins/data/common/search/aggs/buckets/diversified_sampler.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { BucketAggType } from './bucket_agg_type'; +import { BaseAggParams } from '../types'; +import { aggDiversifiedSamplerFnName } from './diversified_sampler_fn'; + +export const DIVERSIFIED_SAMPLER_AGG_NAME = 'diversified_sampler'; + +const title = i18n.translate('data.search.aggs.buckets.diversifiedSamplerTitle', { + defaultMessage: 'Diversified sampler', + description: 'Diversified sampler aggregation title', +}); + +export interface AggParamsDiversifiedSampler extends BaseAggParams { + /** + * Is used to provide values used for de-duplication + */ + field: string; + + /** + * Limits how many top-scoring documents are collected in the sample processed on each shard. + */ + shard_size?: number; + + /** + * Limits how many documents are permitted per choice of de-duplicating value + */ + max_docs_per_value?: number; +} + +/** + * Like the sampler aggregation this is a filtering aggregation used to limit any sub aggregations' processing to a sample of the top-scoring documents. + * The diversified_sampler aggregation adds the ability to limit the number of matches that share a common value. + */ +export const getDiversifiedSamplerBucketAgg = () => + new BucketAggType({ + name: DIVERSIFIED_SAMPLER_AGG_NAME, + title, + customLabels: false, + expressionName: aggDiversifiedSamplerFnName, + params: [ + { + name: 'shard_size', + type: 'number', + }, + { + name: 'max_docs_per_value', + type: 'number', + }, + { + name: 'field', + type: 'field', + }, + ], + }); diff --git a/src/plugins/data/common/search/aggs/buckets/diversified_sampler_fn.test.ts b/src/plugins/data/common/search/aggs/buckets/diversified_sampler_fn.test.ts new file mode 100644 index 0000000000000..e874542289bb2 --- /dev/null +++ b/src/plugins/data/common/search/aggs/buckets/diversified_sampler_fn.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { functionWrapper } from '../test_helpers'; +import { aggDiversifiedSampler } from './diversified_sampler_fn'; + +describe('aggDiversifiedSampler', () => { + const fn = functionWrapper(aggDiversifiedSampler()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ id: 'sampler', schema: 'bucket', field: 'author' }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": "sampler", + "params": Object { + "field": "author", + "max_docs_per_value": undefined, + "shard_size": undefined, + }, + "schema": "bucket", + "type": "diversified_sampler", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + id: 'sampler', + schema: 'bucket', + shard_size: 300, + field: 'author', + max_docs_per_value: 3, + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": "sampler", + "params": Object { + "field": "author", + "max_docs_per_value": 3, + "shard_size": 300, + }, + "schema": "bucket", + "type": "diversified_sampler", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/aggs/buckets/diversified_sampler_fn.ts b/src/plugins/data/common/search/aggs/buckets/diversified_sampler_fn.ts new file mode 100644 index 0000000000000..0e1b235dd576d --- /dev/null +++ b/src/plugins/data/common/search/aggs/buckets/diversified_sampler_fn.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { AggExpressionFunctionArgs, AggExpressionType, BUCKET_TYPES } from '../'; +import { DIVERSIFIED_SAMPLER_AGG_NAME } from './diversified_sampler'; + +export const aggDiversifiedSamplerFnName = 'aggDiversifiedSampler'; + +type Input = any; +type Arguments = AggExpressionFunctionArgs; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition< + typeof aggDiversifiedSamplerFnName, + Input, + Arguments, + Output +>; + +export const aggDiversifiedSampler = (): FunctionDefinition => ({ + name: aggDiversifiedSamplerFnName, + help: i18n.translate('data.search.aggs.function.buckets.diversifiedSampler.help', { + defaultMessage: 'Generates a serialized agg config for a Diversified sampler agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.diversifiedSampler.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.diversifiedSampler.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.diversifiedSampler.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + shard_size: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.diversifiedSampler.shardSize.help', { + defaultMessage: + 'The shard_size parameter limits how many top-scoring documents are collected in the sample processed on each shard.', + }), + }, + max_docs_per_value: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.diversifiedSampler.maxDocsPerValue.help', { + defaultMessage: + 'Limits how many documents are permitted per choice of de-duplicating value.', + }), + }, + field: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.diversifiedSampler.field.help', { + defaultMessage: 'Used to provide values used for de-duplication.', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: DIVERSIFIED_SAMPLER_AGG_NAME, + params: { + ...rest, + }, + }, + }; + }, +}); diff --git a/src/plugins/data/common/search/aggs/buckets/index.ts b/src/plugins/data/common/search/aggs/buckets/index.ts index 421fa0fcfdaf4..bf96a9ef860c0 100644 --- a/src/plugins/data/common/search/aggs/buckets/index.ts +++ b/src/plugins/data/common/search/aggs/buckets/index.ts @@ -38,3 +38,7 @@ export * from './terms_fn'; export * from './terms'; export * from './multi_terms_fn'; export * from './multi_terms'; +export * from './sampler_fn'; +export * from './sampler'; +export * from './diversified_sampler_fn'; +export * from './diversified_sampler'; diff --git a/src/plugins/data/common/search/aggs/buckets/multi_terms.ts b/src/plugins/data/common/search/aggs/buckets/multi_terms.ts index c320c7e242798..02bf6bd12d319 100644 --- a/src/plugins/data/common/search/aggs/buckets/multi_terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/multi_terms.ts @@ -34,6 +34,7 @@ export interface AggParamsMultiTerms extends BaseAggParams { size?: number; otherBucket?: boolean; otherBucketLabel?: string; + separatorLabel?: string; } export const getMultiTermsBucketAgg = () => { @@ -83,6 +84,7 @@ export const getMultiTermsBucketAgg = () => { params: { otherBucketLabel: params.otherBucketLabel, paramsPerField: formats, + separator: agg.params.separatorLabel, }, }; }, @@ -142,6 +144,11 @@ export const getMultiTermsBucketAgg = () => { shouldShow: (agg) => agg.getParam('otherBucket'), write: noop, }, + { + name: 'separatorLabel', + type: 'string', + write: noop, + }, ], }); }; diff --git a/src/plugins/data/common/search/aggs/buckets/multi_terms_fn.ts b/src/plugins/data/common/search/aggs/buckets/multi_terms_fn.ts index 58e49479cd2c1..12b9c6d156548 100644 --- a/src/plugins/data/common/search/aggs/buckets/multi_terms_fn.ts +++ b/src/plugins/data/common/search/aggs/buckets/multi_terms_fn.ts @@ -111,6 +111,12 @@ export const aggMultiTerms = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + separatorLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.multiTerms.separatorLabel.help', { + defaultMessage: 'The separator label used to join each term combination', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/buckets/sampler.ts b/src/plugins/data/common/search/aggs/buckets/sampler.ts new file mode 100644 index 0000000000000..7eb4f74115095 --- /dev/null +++ b/src/plugins/data/common/search/aggs/buckets/sampler.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 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 { BucketAggType } from './bucket_agg_type'; +import { BaseAggParams } from '../types'; +import { aggSamplerFnName } from './sampler_fn'; + +export const SAMPLER_AGG_NAME = 'sampler'; + +const title = i18n.translate('data.search.aggs.buckets.samplerTitle', { + defaultMessage: 'Sampler', + description: 'Sampler aggregation title', +}); + +export interface AggParamsSampler extends BaseAggParams { + /** + * Limits how many top-scoring documents are collected in the sample processed on each shard. + */ + shard_size?: number; +} + +/** + * A filtering aggregation used to limit any sub aggregations' processing to a sample of the top-scoring documents. + */ +export const getSamplerBucketAgg = () => + new BucketAggType({ + name: SAMPLER_AGG_NAME, + title, + customLabels: false, + expressionName: aggSamplerFnName, + params: [ + { + name: 'shard_size', + type: 'number', + }, + ], + }); diff --git a/src/plugins/data/common/search/aggs/buckets/sampler_fn.test.ts b/src/plugins/data/common/search/aggs/buckets/sampler_fn.test.ts new file mode 100644 index 0000000000000..76ef901671e72 --- /dev/null +++ b/src/plugins/data/common/search/aggs/buckets/sampler_fn.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { functionWrapper } from '../test_helpers'; +import { aggSampler } from './sampler_fn'; + +describe('aggSampler', () => { + const fn = functionWrapper(aggSampler()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ id: 'sampler', schema: 'bucket' }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": "sampler", + "params": Object { + "shard_size": undefined, + }, + "schema": "bucket", + "type": "sampler", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + id: 'sampler', + schema: 'bucket', + shard_size: 300, + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": "sampler", + "params": Object { + "shard_size": 300, + }, + "schema": "bucket", + "type": "sampler", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/aggs/buckets/sampler_fn.ts b/src/plugins/data/common/search/aggs/buckets/sampler_fn.ts new file mode 100644 index 0000000000000..2cb30eb70a230 --- /dev/null +++ b/src/plugins/data/common/search/aggs/buckets/sampler_fn.ts @@ -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 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 { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { AggExpressionFunctionArgs, AggExpressionType, BUCKET_TYPES } from '../'; +import { SAMPLER_AGG_NAME } from './sampler'; + +export const aggSamplerFnName = 'aggSampler'; + +type Input = any; +type Arguments = AggExpressionFunctionArgs; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition< + typeof aggSamplerFnName, + Input, + Arguments, + Output +>; + +export const aggSampler = (): FunctionDefinition => ({ + name: aggSamplerFnName, + help: i18n.translate('data.search.aggs.function.buckets.sampler.help', { + defaultMessage: 'Generates a serialized agg config for a Sampler agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.sampler.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.sampler.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.sampler.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + shard_size: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.sampler.shardSize.help', { + defaultMessage: + 'The shard_size parameter limits how many top-scoring documents are collected in the sample processed on each shard.', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: SAMPLER_AGG_NAME, + params: { + ...rest, + }, + }, + }; + }, +}); diff --git a/src/plugins/data/common/search/aggs/metrics/percentile_ranks.ts b/src/plugins/data/common/search/aggs/metrics/percentile_ranks.ts index fb142ee1f77c8..8f976ba979b95 100644 --- a/src/plugins/data/common/search/aggs/metrics/percentile_ranks.ts +++ b/src/plugins/data/common/search/aggs/metrics/percentile_ranks.ts @@ -13,7 +13,8 @@ import { AggTypesDependencies } from '../agg_types'; import { BaseAggParams } from '../types'; import { MetricAggType } from './metric_agg_type'; -import { getResponseAggConfigClass, IResponseAggConfig } from './lib/get_response_agg_config_class'; +import { getResponseAggConfigClass } from './lib/get_response_agg_config_class'; +import type { IResponseAggConfig } from './lib/get_response_agg_config_class'; import { aggPercentileRanksFnName } from './percentile_ranks_fn'; import { getPercentileValue } from './percentiles_get_value'; import { METRIC_TYPES } from './metric_agg_types'; diff --git a/src/plugins/data/common/search/aggs/metrics/percentiles.test.ts b/src/plugins/data/common/search/aggs/metrics/percentiles.test.ts index 26189e022e7c6..17c49e2484a80 100644 --- a/src/plugins/data/common/search/aggs/metrics/percentiles.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/percentiles.test.ts @@ -10,7 +10,7 @@ import { IPercentileAggConfig, getPercentilesMetricAgg } from './percentiles'; import { AggConfigs, IAggConfigs } from '../agg_configs'; import { mockAggTypesRegistry } from '../test_helpers'; import { METRIC_TYPES } from './metric_agg_types'; -import { IResponseAggConfig } from './lib/get_response_agg_config_class'; +import type { IResponseAggConfig } from './lib/get_response_agg_config_class'; describe('AggTypesMetricsPercentilesProvider class', () => { let aggConfigs: IAggConfigs; diff --git a/src/plugins/data/common/search/aggs/metrics/percentiles.ts b/src/plugins/data/common/search/aggs/metrics/percentiles.ts index 07c4ac2bf2646..d0e1c6df77696 100644 --- a/src/plugins/data/common/search/aggs/metrics/percentiles.ts +++ b/src/plugins/data/common/search/aggs/metrics/percentiles.ts @@ -10,7 +10,8 @@ import { i18n } from '@kbn/i18n'; import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; -import { getResponseAggConfigClass, IResponseAggConfig } from './lib/get_response_agg_config_class'; +import { getResponseAggConfigClass } from './lib/get_response_agg_config_class'; +import type { IResponseAggConfig } from './lib/get_response_agg_config_class'; import { aggPercentilesFnName } from './percentiles_fn'; import { getPercentileValue } from './percentiles_get_value'; import { ordinalSuffix } from './lib/ordinal_suffix'; diff --git a/src/plugins/data/common/search/aggs/metrics/percentiles_get_value.ts b/src/plugins/data/common/search/aggs/metrics/percentiles_get_value.ts index 90585909db42a..242a12da35128 100644 --- a/src/plugins/data/common/search/aggs/metrics/percentiles_get_value.ts +++ b/src/plugins/data/common/search/aggs/metrics/percentiles_get_value.ts @@ -7,7 +7,7 @@ */ import { find } from 'lodash'; -import { IResponseAggConfig } from './lib/get_response_agg_config_class'; +import type { IResponseAggConfig } from './lib/get_response_agg_config_class'; export const getPercentileValue = ( agg: TAggConfig, diff --git a/src/plugins/data/common/search/aggs/metrics/std_deviation.ts b/src/plugins/data/common/search/aggs/metrics/std_deviation.ts index fa160e5e9d161..9a4c38e296635 100644 --- a/src/plugins/data/common/search/aggs/metrics/std_deviation.ts +++ b/src/plugins/data/common/search/aggs/metrics/std_deviation.ts @@ -11,7 +11,8 @@ import { i18n } from '@kbn/i18n'; import { MetricAggType } from './metric_agg_type'; import { aggStdDeviationFnName } from './std_deviation_fn'; import { METRIC_TYPES } from './metric_agg_types'; -import { getResponseAggConfigClass, IResponseAggConfig } from './lib/get_response_agg_config_class'; +import { getResponseAggConfigClass } from './lib/get_response_agg_config_class'; +import type { IResponseAggConfig } from './lib/get_response_agg_config_class'; import { KBN_FIELD_TYPES } from '../../../../common'; import { BaseAggParams } from '../types'; diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts index b9a977e0a8a09..74356263845d1 100644 --- a/src/plugins/data/common/search/aggs/types.ts +++ b/src/plugins/data/common/search/aggs/types.ts @@ -90,6 +90,8 @@ import { aggFilteredMetric, aggSinglePercentile, } from './'; +import { AggParamsSampler } from './buckets/sampler'; +import { AggParamsDiversifiedSampler } from './buckets/diversified_sampler'; export type { IAggConfig, AggConfigSerialized } from './agg_config'; export type { CreateAggConfigParams, IAggConfigs } from './agg_configs'; @@ -100,12 +102,10 @@ export type { IMetricAggType } from './metrics/metric_agg_type'; export type { IpRangeKey } from './buckets/lib/ip_range'; export type { OptionedValueProp } from './param_types/optioned'; -/** @internal */ export interface AggsCommonSetup { types: AggTypesRegistrySetup; } -/** @internal */ export interface AggsCommonStart { calculateAutoTimeExpression: ReturnType; datatableUtilities: { @@ -129,14 +129,12 @@ export interface AggsCommonStart { */ export type AggsStart = Assign; -/** @internal */ export interface BaseAggParams { json?: string; customLabel?: string; timeShift?: string; } -/** @internal */ export interface AggExpressionType { type: 'agg_type'; value: AggConfigSerialized; @@ -166,6 +164,8 @@ export interface AggParamsMapping { [BUCKET_TYPES.DATE_HISTOGRAM]: AggParamsDateHistogram; [BUCKET_TYPES.TERMS]: AggParamsTerms; [BUCKET_TYPES.MULTI_TERMS]: AggParamsMultiTerms; + [BUCKET_TYPES.SAMPLER]: AggParamsSampler; + [BUCKET_TYPES.DIVERSIFIED_SAMPLER]: AggParamsDiversifiedSampler; [METRIC_TYPES.AVG]: AggParamsAvg; [METRIC_TYPES.CARDINALITY]: AggParamsCardinality; [METRIC_TYPES.COUNT]: BaseAggParams; diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts index 76112980c55fb..8510acf1572c7 100644 --- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts +++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts @@ -13,6 +13,7 @@ import { IFieldFormat, SerializedFieldFormat, } from '../../../../../field_formats/common'; +import { MultiFieldKey } from '../buckets/multi_field_key'; import { getAggsFormats } from './get_aggs_formats'; const getAggFormat = ( @@ -119,4 +120,35 @@ describe('getAggsFormats', () => { expect(format.convert('__missing__')).toBe(mapping.params.missingBucketLabel); expect(getFormat).toHaveBeenCalledTimes(3); }); + + test('uses a default separator for multi terms', () => { + const terms = ['source', 'geo.src', 'geo.dest']; + const mapping = { + id: 'multi_terms', + params: { + paramsPerField: Array(terms.length).fill({ id: 'terms' }), + }, + }; + + const format = getAggFormat(mapping, getFormat); + + expect(format.convert(new MultiFieldKey({ key: terms }))).toBe('source › geo.src › geo.dest'); + expect(getFormat).toHaveBeenCalledTimes(terms.length); + }); + + test('uses a custom separator for multi terms when passed', () => { + const terms = ['source', 'geo.src', 'geo.dest']; + const mapping = { + id: 'multi_terms', + params: { + paramsPerField: Array(terms.length).fill({ id: 'terms' }), + separator: ' - ', + }, + }; + + const format = getAggFormat(mapping, getFormat); + + expect(format.convert(new MultiFieldKey({ key: terms }))).toBe('source - geo.src - geo.dest'); + expect(getFormat).toHaveBeenCalledTimes(terms.length); + }); }); diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts index aade8bc70e4ee..f14f981fdec65 100644 --- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts +++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts @@ -143,9 +143,11 @@ export function getAggsFormats(getFieldFormat: GetFieldFormat): FieldFormatInsta return params.otherBucketLabel; } + const joinTemplate = params.separator ?? ' › '; + return (val as MultiFieldKey).keys .map((valPart, i) => formats[i].convert(valPart, type)) - .join(' › '); + .join(joinTemplate); }; getConverterFor = (type: FieldFormatsContentType) => (val: string) => this.convert(val, type); }, diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index a44613cb98b50..eefaf8a9dcd54 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -14,7 +14,7 @@ import type { IAggConfigs } from '../../aggs'; import type { ISearchSource } from '../../search_source'; import { searchSourceCommonMock, searchSourceInstanceMock } from '../../search_source/mocks'; -import { handleRequest, RequestHandlerParams } from './request_handler'; +import { handleRequest } from './request_handler'; jest.mock('../../tabify', () => ({ tabifyAggResponse: jest.fn(), @@ -25,7 +25,7 @@ import { of } from 'rxjs'; import { toArray } from 'rxjs/operators'; describe('esaggs expression function - public', () => { - let mockParams: MockedKeys; + let mockParams: MockedKeys[0]>; beforeEach(() => { jest.clearAllMocks(); diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index 87c1685c9730d..d395baed2f08e 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -17,8 +17,7 @@ import { IAggConfigs } from '../../aggs'; import { ISearchStartSearchSource } from '../../search_source'; import { tabifyAggResponse } from '../../tabify'; -/** @internal */ -export interface RequestHandlerParams { +interface RequestHandlerParams { abortSignal?: AbortSignal; aggs: IAggConfigs; filters?: Filter[]; diff --git a/src/plugins/data/common/search/expressions/esdsl.ts b/src/plugins/data/common/search/expressions/esdsl.ts index faa43dab65657..69e3c54e43806 100644 --- a/src/plugins/data/common/search/expressions/esdsl.ts +++ b/src/plugins/data/common/search/expressions/esdsl.ts @@ -34,8 +34,7 @@ export type EsdslExpressionFunctionDefinition = ExpressionFunctionDefinition< Output >; -/** @internal */ -export interface EsdslStartDependencies { +interface EsdslStartDependencies { search: ISearchGeneric; uiSettingsClient: UiSettingsCommon; } diff --git a/src/plugins/data/common/search/expressions/kibana_context.ts b/src/plugins/data/common/search/expressions/kibana_context.ts index 47ca24b5be42b..6e38e2a3949d5 100644 --- a/src/plugins/data/common/search/expressions/kibana_context.ts +++ b/src/plugins/data/common/search/expressions/kibana_context.ts @@ -19,7 +19,6 @@ import { KibanaTimerangeOutput } from './timerange'; import { SavedObjectReference } from '../../../../../core/types'; import { SavedObjectsClientCommon } from '../..'; -/** @internal */ export interface KibanaContextStartDependencies { savedObjectsClient: SavedObjectsClientCommon; } diff --git a/src/plugins/data/common/search/search_source/extract_references.ts b/src/plugins/data/common/search/search_source/extract_references.ts index de32836ced124..954d336cb8a92 100644 --- a/src/plugins/data/common/search/search_source/extract_references.ts +++ b/src/plugins/data/common/search/search_source/extract_references.ts @@ -14,7 +14,7 @@ import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../data/common'; export const extractReferences = ( state: SerializedSearchSourceFields -): [SerializedSearchSourceFields & { indexRefName?: string }, SavedObjectReference[]] => { +): [SerializedSearchSourceFields, SavedObjectReference[]] => { let searchSourceFields: SerializedSearchSourceFields & { indexRefName?: string } = { ...state }; const references: SavedObjectReference[] = []; if (searchSourceFields.index) { diff --git a/src/plugins/data/common/search/search_source/fetch/get_search_params.ts b/src/plugins/data/common/search/search_source/fetch/get_search_params.ts index 28ee7993c175c..ae01dcf4ea051 100644 --- a/src/plugins/data/common/search/search_source/fetch/get_search_params.ts +++ b/src/plugins/data/common/search/search_source/fetch/get_search_params.ts @@ -9,7 +9,7 @@ import { UI_SETTINGS } from '../../../constants'; import { GetConfigFn } from '../../../types'; import { ISearchRequestParams } from '../../index'; -import { SearchRequest } from './types'; +import type { SearchRequest } from './types'; const sessionId = Date.now(); diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index dee5c09a6b858..77ba2a761fbf0 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -40,6 +40,10 @@ export const searchSourceInstanceMock: MockedKeys = { export const searchSourceCommonMock: jest.Mocked = { create: jest.fn().mockReturnValue(searchSourceInstanceMock), createEmpty: jest.fn().mockReturnValue(searchSourceInstanceMock), + telemetry: jest.fn(), + getAllMigrations: jest.fn(), + inject: jest.fn(), + extract: jest.fn(), }; export const createSearchSourceMock = (fields?: SearchSourceFields, response?: any) => diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 3ac6b623fbc80..8acdb0514cccb 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -95,7 +95,8 @@ import type { SearchSourceFields, SearchSourceOptions, } from './types'; -import { FetchHandlers, getSearchParamsFromRequest, RequestFailure, SearchRequest } from './fetch'; +import { getSearchParamsFromRequest, RequestFailure } from './fetch'; +import type { FetchHandlers, SearchRequest } from './fetch'; import { getRequestInspectorStats, getResponseInspectorStats } from './inspect'; import { diff --git a/src/plugins/data/common/search/search_source/search_source_service.test.ts b/src/plugins/data/common/search/search_source/search_source_service.test.ts index dc63b96d5258d..a1b49fc433925 100644 --- a/src/plugins/data/common/search/search_source/search_source_service.test.ts +++ b/src/plugins/data/common/search/search_source/search_source_service.test.ts @@ -28,7 +28,14 @@ describe('SearchSource service', () => { dependencies ); - expect(Object.keys(start)).toEqual(['create', 'createEmpty']); + expect(Object.keys(start)).toEqual([ + 'create', + 'createEmpty', + 'extract', + 'inject', + 'getAllMigrations', + 'telemetry', + ]); }); }); }); diff --git a/src/plugins/data/common/search/search_source/search_source_service.ts b/src/plugins/data/common/search/search_source/search_source_service.ts index 886420365f548..a97596d322ccd 100644 --- a/src/plugins/data/common/search/search_source/search_source_service.ts +++ b/src/plugins/data/common/search/search_source/search_source_service.ts @@ -6,8 +6,18 @@ * Side Public License, v 1. */ -import { createSearchSource, SearchSource, SearchSourceDependencies } from './'; +import { mapValues } from 'lodash'; +import { + createSearchSource, + extractReferences, + injectReferences, + SearchSource, + SearchSourceDependencies, + SerializedSearchSourceFields, +} from './'; import { IndexPatternsContract } from '../..'; +import { mergeMigrationFunctionMaps } from '../../../../kibana_utils/common'; +import { getAllMigrations as filtersGetAllMigrations } from '../../query/persistable_state'; export class SearchSourceService { public setup() {} @@ -24,6 +34,28 @@ export class SearchSourceService { createEmpty: () => { return new SearchSource({}, dependencies); }, + extract: (state: SerializedSearchSourceFields) => { + const [newState, references] = extractReferences(state); + return { state: newState, references }; + }, + inject: injectReferences, + getAllMigrations: () => { + const searchSourceMigrations = {}; + + // we don't know if embeddables have any migrations defined so we need to fetch them and map the received functions so we pass + // them the correct input and that we correctly map the response + const filterMigrations = mapValues(filtersGetAllMigrations(), (migrate) => { + return (state: SerializedSearchSourceFields) => ({ + ...state, + filter: migrate(state.filter), + }); + }); + + return mergeMigrationFunctionMaps(searchSourceMigrations, filterMigrations); + }, + telemetry: () => { + return {}; + }, }; } diff --git a/src/plugins/data/common/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts index acfdf17263169..94697ba9521e9 100644 --- a/src/plugins/data/common/search/search_source/types.ts +++ b/src/plugins/data/common/search/search_source/types.ts @@ -12,7 +12,8 @@ import { SerializableRecord } from '@kbn/utility-types'; import { Query } from '../..'; import { Filter } from '../../es_query'; import { IndexPattern } from '../..'; -import { SearchSource } from './search_source'; +import type { SearchSource } from './search_source'; +import { PersistableStateService } from '../../../../kibana_utils/common'; /** * search source interface @@ -24,7 +25,8 @@ export type ISearchSource = Pick; * high level search service * @public */ -export interface ISearchStartSearchSource { +export interface ISearchStartSearchSource + extends PersistableStateService { /** * creates {@link SearchSource} based on provided serialized {@link SearchSourceFields} * @param fields @@ -43,15 +45,17 @@ export enum SortDirection { desc = 'desc', } -export interface SortDirectionFormat { +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SortDirectionFormat = { order: SortDirection; format?: string; -} +}; -export interface SortDirectionNumeric { +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SortDirectionNumeric = { order: SortDirection; numeric_type?: 'double' | 'long' | 'date' | 'date_nanos'; -} +}; export type EsQuerySortValue = Record< string, @@ -114,7 +118,8 @@ export interface SearchSourceFields { parent?: SearchSourceFields; } -export interface SerializedSearchSourceFields { +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SerializedSearchSourceFields = { type?: string; /** * {@link Query} @@ -159,7 +164,7 @@ export interface SerializedSearchSourceFields { terminate_after?: number; parent?: SerializedSearchSourceFields; -} +}; export interface SearchSourceOptions { callParentStartHandlers?: boolean; diff --git a/src/plugins/data/common/search/tabify/get_columns.test.ts b/src/plugins/data/common/search/tabify/get_columns.test.ts index d679b3fb36311..1741abfe729d7 100644 --- a/src/plugins/data/common/search/tabify/get_columns.test.ts +++ b/src/plugins/data/common/search/tabify/get_columns.test.ts @@ -7,7 +7,7 @@ */ import { tabifyGetColumns } from './get_columns'; -import { TabbedAggColumn } from './types'; +import type { TabbedAggColumn } from './types'; import { AggConfigs } from '../aggs'; import { mockAggTypesRegistry } from '../aggs/test_helpers'; diff --git a/src/plugins/data/common/search/tabify/get_columns.ts b/src/plugins/data/common/search/tabify/get_columns.ts index 62798ba8bf680..8957c96a69881 100644 --- a/src/plugins/data/common/search/tabify/get_columns.ts +++ b/src/plugins/data/common/search/tabify/get_columns.ts @@ -8,7 +8,7 @@ import { groupBy } from 'lodash'; import { IAggConfig } from '../aggs'; -import { TabbedAggColumn } from './types'; +import type { TabbedAggColumn } from './types'; const getColumn = (agg: IAggConfig, i: number): TabbedAggColumn => { let name = ''; diff --git a/src/plugins/data/common/search/tabify/response_writer.test.ts b/src/plugins/data/common/search/tabify/response_writer.test.ts index cee297d255db3..ec131458b8510 100644 --- a/src/plugins/data/common/search/tabify/response_writer.test.ts +++ b/src/plugins/data/common/search/tabify/response_writer.test.ts @@ -9,7 +9,7 @@ import { TabbedAggResponseWriter } from './response_writer'; import { AggConfigs, BUCKET_TYPES, METRIC_TYPES } from '../aggs'; import { mockAggTypesRegistry } from '../aggs/test_helpers'; -import { TabbedResponseWriterOptions } from './types'; +import type { TabbedResponseWriterOptions } from './types'; describe('TabbedAggResponseWriter class', () => { let responseWriter: TabbedAggResponseWriter; diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts index d3273accff974..5b1247a8f1719 100644 --- a/src/plugins/data/common/search/tabify/tabify.ts +++ b/src/plugins/data/common/search/tabify/tabify.ts @@ -9,7 +9,7 @@ import { get } from 'lodash'; import { TabbedAggResponseWriter } from './response_writer'; import { TabifyBuckets } from './buckets'; -import { TabbedResponseWriterOptions } from './types'; +import type { TabbedResponseWriterOptions } from './types'; import { AggResponseBucket } from './types'; import { AggGroupNames, IAggConfigs } from '../aggs'; diff --git a/src/plugins/data/common/search/tabify/tabify_docs.ts b/src/plugins/data/common/search/tabify/tabify_docs.ts index 43b6155f6662f..08172a918c042 100644 --- a/src/plugins/data/common/search/tabify/tabify_docs.ts +++ b/src/plugins/data/common/search/tabify/tabify_docs.ts @@ -48,7 +48,7 @@ function isValidMetaFieldName(field: string): field is ValidMetaFieldNames { return (VALID_META_FIELD_NAMES as string[]).includes(field); } -export interface TabifyDocsOptions { +interface TabifyDocsOptions { shallow?: boolean; /** * If set to `false` the _source of the document, if requested, won't be diff --git a/src/plugins/data/common/search/tabify/types.ts b/src/plugins/data/common/search/tabify/types.ts index 9fadb0ef860e3..bf0a99725e2ab 100644 --- a/src/plugins/data/common/search/tabify/types.ts +++ b/src/plugins/data/common/search/tabify/types.ts @@ -22,7 +22,6 @@ export interface TimeRangeInformation { timeFields: string[]; } -/** @internal **/ export interface TabbedResponseWriterOptions { metricsAtAllLevels: boolean; partialRows: boolean; diff --git a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts index ea17e91d085e7..2ae1805c8aa28 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts @@ -13,8 +13,7 @@ import { esFilters, IFieldType, RangeFilterParams } from '../../../public'; import { getIndexPatterns, getSearchService } from '../../../public/services'; import { AggConfigSerialized } from '../../../common/search/aggs'; -/** @internal */ -export interface RangeSelectDataContext { +interface RangeSelectDataContext { table: Datatable; column: number; range: number[]; diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts index e4854dac9408b..5163f979d3ff5 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts @@ -9,10 +9,7 @@ import { IndexPatternsContract } from '../../../public'; import { dataPluginMock } from '../../../public/mocks'; import { setIndexPatterns, setSearchService } from '../../../public/services'; -import { - createFiltersFromValueClickAction, - ValueClickDataContext, -} from './create_filters_from_value_click'; +import { createFiltersFromValueClickAction } from './create_filters_from_value_click'; import { FieldFormatsGetConfigFn, BytesFormat } from '../../../../field_formats/common'; import { RangeFilter } from '@kbn/es-query'; @@ -22,7 +19,7 @@ const mockField = { }; describe('createFiltersFromValueClick', () => { - let dataPoints: ValueClickDataContext['data']; + let dataPoints: Parameters[0]['data']; beforeEach(() => { dataPoints = [ diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts index e1088b42e37b6..23ab718e512bd 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts @@ -12,8 +12,7 @@ import { esFilters, Filter } from '../../../public'; import { getIndexPatterns, getSearchService } from '../../../public/services'; import { AggConfigSerialized } from '../../../common/search/aggs'; -/** @internal */ -export interface ValueClickDataContext { +interface ValueClickDataContext { data: Array<{ table: Pick; column: number; diff --git a/src/plugins/data/public/autocomplete/autocomplete_service.ts b/src/plugins/data/public/autocomplete/autocomplete_service.ts index 67efbe2af29ce..0d21c7e765501 100644 --- a/src/plugins/data/public/autocomplete/autocomplete_service.ts +++ b/src/plugins/data/public/autocomplete/autocomplete_service.ts @@ -8,13 +8,13 @@ import { CoreSetup, PluginInitializerContext } from 'src/core/public'; import moment from 'moment'; -import { TimefilterSetup } from '../query'; +import type { TimefilterSetup } from '../query'; import { QuerySuggestionGetFn } from './providers/query_suggestion_provider'; import { getEmptyValueSuggestions, setupValueSuggestionProvider, - ValueSuggestionsGetFn, } from './providers/value_suggestion_provider'; +import type { ValueSuggestionsGetFn } from './providers/value_suggestion_provider'; import { ConfigSchema } from '../../config'; import { UsageCollectionSetup } from '../../../usage_collection/public'; diff --git a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts index 7ecd371e39db7..4a68c7232ea7e 100644 --- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts +++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts @@ -7,8 +7,9 @@ */ import { stubIndexPattern, stubFields } from '../../stubs'; -import { TimefilterSetup } from '../../query'; -import { setupValueSuggestionProvider, ValueSuggestionsGetFn } from './value_suggestion_provider'; +import type { TimefilterSetup } from '../../query'; +import { setupValueSuggestionProvider } from './value_suggestion_provider'; +import type { ValueSuggestionsGetFn } from './value_suggestion_provider'; import { IUiSettingsClient, CoreSetup } from 'kibana/public'; import { UI_SETTINGS } from '../../../common'; diff --git a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts index 588bac4739c53..31f886daeb4cc 100644 --- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts @@ -11,7 +11,7 @@ import { buildQueryFromFilters } from '@kbn/es-query'; import { memoize } from 'lodash'; import { CoreSetup } from 'src/core/public'; import { IIndexPattern, IFieldType, UI_SETTINGS, ValueSuggestionsMethod } from '../../../common'; -import { TimefilterSetup } from '../../query'; +import type { TimefilterSetup } from '../../query'; import { AutocompleteUsageCollector } from '../collectors'; export type ValueSuggestionsGetFn = (args: ValueSuggestionsGetFnArgs) => Promise; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 25f649f69a052..7d6983725b179 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -11,7 +11,7 @@ import './index.scss'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { ConfigSchema } from '../config'; import { Storage, IStorageWrapper, createStartServicesGetter } from '../../kibana_utils/public'; -import { +import type { DataPublicPluginSetup, DataPublicPluginStart, DataSetupDependencies, diff --git a/src/plugins/data/public/query/filter_manager/filter_manager.ts b/src/plugins/data/public/query/filter_manager/filter_manager.ts index f076a2c591fb1..bfedf444cf23e 100644 --- a/src/plugins/data/public/query/filter_manager/filter_manager.ts +++ b/src/plugins/data/public/query/filter_manager/filter_manager.ts @@ -14,7 +14,6 @@ import { IUiSettingsClient } from 'src/core/public'; import { isFilterPinned, onlyDisabledFiltersChanged, Filter } from '@kbn/es-query'; import { sortFilters } from './lib/sort_filters'; import { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; -import { PartitionedFilters } from './types'; import { FilterStateStore, @@ -31,6 +30,11 @@ import { telemetry, } from '../../../common/query/persistable_state'; +interface PartitionedFilters { + globalFilters: Filter[]; + appFilters: Filter[]; +} + export class FilterManager implements PersistableStateService { private filters: Filter[] = []; private updated$: Subject = new Subject(); diff --git a/src/plugins/data/public/query/lib/get_default_query.ts b/src/plugins/data/public/query/lib/get_default_query.ts index 015c128171a8e..fd571e46083f5 100644 --- a/src/plugins/data/public/query/lib/get_default_query.ts +++ b/src/plugins/data/public/query/lib/get_default_query.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export type QueryLanguage = 'kuery' | 'lucene'; +type QueryLanguage = 'kuery' | 'lucene'; export function getDefaultQuery(language: QueryLanguage = 'kuery') { return { diff --git a/src/plugins/data/public/query/query_service.ts b/src/plugins/data/public/query/query_service.ts index 314f13e3524db..dc6b9586b0b4b 100644 --- a/src/plugins/data/public/query/query_service.ts +++ b/src/plugins/data/public/query/query_service.ts @@ -12,10 +12,12 @@ import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { buildEsQuery } from '@kbn/es-query'; import { FilterManager } from './filter_manager'; import { createAddToQueryLog } from './lib'; -import { TimefilterService, TimefilterSetup } from './timefilter'; +import { TimefilterService } from './timefilter'; +import type { TimefilterSetup } from './timefilter'; import { createSavedQueryService } from './saved_query/saved_query_service'; import { createQueryStateObservable } from './state_sync/create_global_query_observable'; -import { QueryStringContract, QueryStringManager } from './query_string'; +import type { QueryStringContract } from './query_string'; +import { QueryStringManager } from './query_string'; import { getEsQueryConfig, TimeRange } from '../../common'; import { getUiSettings } from '../services'; import { NowProviderInternalContract } from '../now_provider'; diff --git a/src/plugins/data/public/query/query_string/query_string_manager.mock.ts b/src/plugins/data/public/query/query_string/query_string_manager.mock.ts index 976d3ce13e7de..6d20f2a4bea34 100644 --- a/src/plugins/data/public/query/query_string/query_string_manager.mock.ts +++ b/src/plugins/data/public/query/query_string/query_string_manager.mock.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { QueryStringContract } from '.'; +import type { QueryStringContract } from '.'; import { Observable } from 'rxjs'; const createSetupContractMock = () => { diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts index 047051c302083..57af09a0ea824 100644 --- a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts +++ b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts @@ -8,7 +8,7 @@ import { createSavedQueryService } from './saved_query_service'; import { httpServiceMock } from '../../../../../core/public/mocks'; -import { SavedQueryAttributes } from '../../../common'; +import type { SavedQueryAttributes } from '../../../common'; const http = httpServiceMock.createStartContract(); diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.ts b/src/plugins/data/public/query/saved_query/saved_query_service.ts index 17b47c78c7000..b5a21e2ac2095 100644 --- a/src/plugins/data/public/query/saved_query/saved_query_service.ts +++ b/src/plugins/data/public/query/saved_query/saved_query_service.ts @@ -8,7 +8,7 @@ import { HttpStart } from 'src/core/public'; import { SavedQuery } from './types'; -import { SavedQueryAttributes } from '../../../common'; +import type { SavedQueryAttributes } from '../../../common'; export const createSavedQueryService = (http: HttpStart) => { const createQuery = async (attributes: SavedQueryAttributes, { overwrite = false } = {}) => { diff --git a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts b/src/plugins/data/public/query/state_sync/create_global_query_observable.ts index 3c94d6eb3c056..3577478154c31 100644 --- a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts +++ b/src/plugins/data/public/query/state_sync/create_global_query_observable.ts @@ -9,12 +9,12 @@ import { Observable, Subscription } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import { isFilterPinned } from '@kbn/es-query'; -import { TimefilterSetup } from '../timefilter'; +import type { TimefilterSetup } from '../timefilter'; import { FilterManager } from '../filter_manager'; import { QueryState, QueryStateChange } from './index'; import { createStateContainer } from '../../../../kibana_utils/public'; import { compareFilters, COMPARE_ALL_OPTIONS } from '../../../common'; -import { QueryStringContract } from '../query_string'; +import type { QueryStringContract } from '../query_string'; export function createQueryStateObservable({ timefilter: { timefilter }, diff --git a/src/plugins/data/public/query/timefilter/lib/diff_time_picker_vals.ts b/src/plugins/data/public/query/timefilter/lib/diff_time_picker_vals.ts index 2d815ea168f6b..9b50c8d93d496 100644 --- a/src/plugins/data/public/query/timefilter/lib/diff_time_picker_vals.ts +++ b/src/plugins/data/public/query/timefilter/lib/diff_time_picker_vals.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { RefreshInterval } from '../../../../common'; -import { InputTimeRange } from '../types'; +import type { InputTimeRange } from '../types'; const valueOf = function (o: any) { if (o) return o.valueOf(); diff --git a/src/plugins/data/public/query/timefilter/timefilter.ts b/src/plugins/data/public/query/timefilter/timefilter.ts index f3520abb2f46e..e13e8b17a7f43 100644 --- a/src/plugins/data/public/query/timefilter/timefilter.ts +++ b/src/plugins/data/public/query/timefilter/timefilter.ts @@ -11,7 +11,7 @@ import { Subject, BehaviorSubject } from 'rxjs'; import moment from 'moment'; import { PublicMethodsOf } from '@kbn/utility-types'; import { areRefreshIntervalsDifferent, areTimeRangesDifferent } from './lib/diff_time_picker_vals'; -import { TimefilterConfig, InputTimeRange, TimeRangeBounds } from './types'; +import type { TimefilterConfig, InputTimeRange, TimeRangeBounds } from './types'; import { NowProviderInternalContract } from '../../now_provider'; import { calculateBounds, diff --git a/src/plugins/data/public/search/aggs/aggs_service.test.ts b/src/plugins/data/public/search/aggs/aggs_service.test.ts index 20e07360a68e5..c7df4354cc76b 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.test.ts @@ -53,7 +53,7 @@ describe('AggsService - public', () => { test('registers default agg types', () => { service.setup(setupDeps); const start = service.start(startDeps); - expect(start.types.getAll().buckets.length).toBe(12); + expect(start.types.getAll().buckets.length).toBe(14); expect(start.types.getAll().metrics.length).toBe(23); }); @@ -69,7 +69,7 @@ describe('AggsService - public', () => { ); const start = service.start(startDeps); - expect(start.types.getAll().buckets.length).toBe(13); + expect(start.types.getAll().buckets.length).toBe(15); expect(start.types.getAll().buckets.some(({ name }) => name === 'foo')).toBe(true); expect(start.types.getAll().metrics.length).toBe(24); expect(start.types.getAll().metrics.some(({ name }) => name === 'bar')).toBe(true); diff --git a/src/plugins/data/public/search/collectors/types.ts b/src/plugins/data/public/search/collectors/types.ts index 49c240d1ccb16..d0a2e61f45109 100644 --- a/src/plugins/data/public/search/collectors/types.ts +++ b/src/plugins/data/public/search/collectors/types.ts @@ -68,9 +68,6 @@ export enum SEARCH_EVENT_TYPE { SESSIONS_LIST_LOADED = 'sessionsListLoaded', } -/** - * @internal - */ export interface SearchUsageCollector { trackQueryTimedOut: () => Promise; trackSessionIndicatorTourLoading: () => Promise; diff --git a/src/plugins/data/public/search/errors/types.ts b/src/plugins/data/public/search/errors/types.ts index d541e53be78f9..8f18ab06fcd94 100644 --- a/src/plugins/data/public/search/errors/types.ts +++ b/src/plugins/data/public/search/errors/types.ts @@ -32,7 +32,7 @@ export interface Reason { }; } -export interface IEsErrorAttributes { +interface IEsErrorAttributes { type: string; reason: string; root_cause?: Reason[]; diff --git a/src/plugins/data/public/search/fetch/handle_response.tsx b/src/plugins/data/public/search/fetch/handle_response.tsx index 9e68209af2b92..10b2f69a2a320 100644 --- a/src/plugins/data/public/search/fetch/handle_response.tsx +++ b/src/plugins/data/public/search/fetch/handle_response.tsx @@ -13,7 +13,7 @@ import { IKibanaSearchResponse } from 'src/plugins/data/common'; import { ShardFailureOpenModalButton } from '../../ui/shard_failure_modal'; import { toMountPoint } from '../../../../kibana_react/public'; import { getNotifications } from '../../services'; -import { SearchRequest } from '..'; +import type { SearchRequest } from '..'; export function handleResponse(request: SearchRequest, response: IKibanaSearchResponse) { const { rawResponse } = response; diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 562b367b92c92..b82e0776777c5 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -8,7 +8,7 @@ import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; import { searchSourceMock } from './search_source/mocks'; -import { ISearchSetup, ISearchStart } from './types'; +import type { ISearchSetup, ISearchStart } from './types'; import { getSessionsClientMock, getSessionServiceMock } from './session/mocks'; import { createSearchUsageCollectorMock } from './collectors/mocks'; diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index ecc0e84917251..76aae8582287d 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -15,7 +15,7 @@ import { } from 'src/core/public'; import { BehaviorSubject } from 'rxjs'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; -import { ISearchSetup, ISearchStart } from './types'; +import type { ISearchSetup, ISearchStart } from './types'; import { handleResponse } from './fetch'; import { diff --git a/src/plugins/data/public/search/search_source/mocks.ts b/src/plugins/data/public/search/search_source/mocks.ts index 75ab8dbac7d2d..169ac4b84a505 100644 --- a/src/plugins/data/public/search/search_source/mocks.ts +++ b/src/plugins/data/public/search/search_source/mocks.ts @@ -7,7 +7,7 @@ */ import { searchSourceCommonMock } from '../../../common/search/search_source/mocks'; -import { ISearchStart } from '../types'; +import type { ISearchStart } from '../types'; function createStartContract(): jest.Mocked { return searchSourceCommonMock; diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index dee0216530205..c6706ff8cf72d 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -9,7 +9,8 @@ import { BehaviorSubject } from 'rxjs'; import { ISessionsClient } from './sessions_client'; import { ISessionService } from './session_service'; -import { SearchSessionState, SessionMeta } from './search_session_state'; +import { SearchSessionState } from './search_session_state'; +import type { SessionMeta } from './search_session_state'; export function getSessionsClientMock(): jest.Mocked { return { diff --git a/src/plugins/data/public/search/session/search_session_state.test.ts b/src/plugins/data/public/search/session/search_session_state.test.ts index ef18275da12fa..1137ceddb0da6 100644 --- a/src/plugins/data/public/search/session/search_session_state.test.ts +++ b/src/plugins/data/public/search/session/search_session_state.test.ts @@ -7,7 +7,7 @@ */ import { createSessionStateContainer, SearchSessionState } from './search_session_state'; -import { SearchSessionSavedObject } from './sessions_client'; +import type { SearchSessionSavedObject } from './sessions_client'; import { SearchSessionStatus } from '../../../common'; const mockSavedObject: SearchSessionSavedObject = { diff --git a/src/plugins/data/public/search/session/search_session_state.ts b/src/plugins/data/public/search/session/search_session_state.ts index 73c75d046da96..c714a3e387641 100644 --- a/src/plugins/data/public/search/session/search_session_state.ts +++ b/src/plugins/data/public/search/session/search_session_state.ts @@ -11,7 +11,7 @@ import deepEqual from 'fast-deep-equal'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; import { createStateContainer, StateContainer } from '../../../../kibana_utils/public'; -import { SearchSessionSavedObject } from './sessions_client'; +import type { SearchSessionSavedObject } from './sessions_client'; /** * Possible state that current session can be in diff --git a/src/plugins/data/public/search/session/session_service.test.ts b/src/plugins/data/public/search/session/session_service.test.ts index 4a11cdb38bb7d..ad131fbea60b2 100644 --- a/src/plugins/data/public/search/session/session_service.test.ts +++ b/src/plugins/data/public/search/session/session_service.test.ts @@ -15,7 +15,7 @@ import { SearchSessionState } from './search_session_state'; import { createNowProviderMock } from '../../now_provider/mocks'; import { NowProviderInternalContract } from '../../now_provider'; import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; -import { SearchSessionSavedObject, ISessionsClient } from './sessions_client'; +import type { SearchSessionSavedObject, ISessionsClient } from './sessions_client'; import { SearchSessionStatus } from '../../../common'; import { CoreStart } from 'kibana/public'; diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 360e8808c186d..9a02e336ecf86 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -16,8 +16,8 @@ import { } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { ConfigSchema } from '../../../config'; -import { - createSessionStateContainer, +import { createSessionStateContainer } from './search_session_state'; +import type { SearchSessionState, SessionMeta, SessionStateContainer, @@ -31,7 +31,7 @@ import { formatSessionName } from './lib/session_name_formatter'; export type ISessionService = PublicContract; -export interface TrackSearchDescriptor { +interface TrackSearchDescriptor { abort: () => void; } @@ -66,7 +66,7 @@ export interface SearchSessionInfoProvider

; const LazyFilterLabel = React.lazy(() => import('./filter_editor/lib/filter_label')); -export const FilterLabel = (props: FilterLabelProps) => ( +export const FilterLabel = (props: React.ComponentProps) => ( }> ); -import type { FilterItemProps } from './filter_item'; - const LazyFilterItem = React.lazy(() => import('./filter_item')); -export const FilterItem = (props: FilterItemProps) => ( +export const FilterItem = (props: React.ComponentProps) => ( }> diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index a78055f0d61a1..a5f59b976d3ba 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -24,7 +24,8 @@ import { IDataPluginServices, IIndexPattern, TimeRange, TimeHistoryContract, Que import { useKibana, withKibana } from '../../../../kibana_react/public'; import QueryStringInputUI from './query_string_input'; import { UI_SETTINGS } from '../../../common'; -import { PersistedLog, getQueryLog } from '../../query'; +import { getQueryLog } from '../../query'; +import type { PersistedLog } from '../../query'; import { NoDataPopover } from './no_data_popover'; const QueryStringInput = withKibana(QueryStringInputUI); diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 5d3e359ca5fc5..2e150b2c1e1bc 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -34,8 +34,9 @@ import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; import { KibanaReactContextValue, toMountPoint } from '../../../../kibana_react/public'; import { fetchIndexPatterns } from './fetch_index_patterns'; import { QueryLanguageSwitcher } from './language_switcher'; -import { PersistedLog, getQueryLog, matchPairs, toUser, fromUser } from '../../query'; -import { SuggestionsListSize } from '../typeahead/suggestions_component'; +import { getQueryLog, matchPairs, toUser, fromUser } from '../../query'; +import type { PersistedLog } from '../../query'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { SuggestionsComponent } from '..'; import { KIBANA_USER_QUERY_LANGUAGE_KEY, getFieldSubtypeNested } from '../../../common'; diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index 7b7538441c38f..fda6a74e4b500 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -12,7 +12,8 @@ import { CoreStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { KibanaContextProvider } from '../../../../kibana_react/public'; import { QueryStart, SavedQuery } from '../../query'; -import { SearchBar, SearchBarOwnProps } from './'; +import { SearchBar } from '.'; +import type { SearchBarOwnProps } from '.'; import { useFilterManager } from './lib/use_filter_manager'; import { useTimefilter } from './lib/use_timefilter'; import { useSavedQuery } from './lib/use_saved_query'; diff --git a/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts b/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts index 508ae711f52a9..713020f249ae3 100644 --- a/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts +++ b/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts @@ -9,7 +9,7 @@ import { useState, useEffect } from 'react'; import { Subscription } from 'rxjs'; import { Query } from '../../..'; -import { QueryStringContract } from '../../../query/query_string'; +import type { QueryStringContract } from '../../../query/query_string'; interface UseQueryStringProps { query?: Query; diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 385f052adece6..3fef455be41c3 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -18,7 +18,7 @@ import { Query, Filter } from '@kbn/es-query'; import { withKibana, KibanaReactContextValue } from '../../../../kibana_react/public'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; -import { SavedQueryAttributes, TimeHistoryContract, SavedQuery } from '../../query'; +import type { SavedQueryAttributes, TimeHistoryContract, SavedQuery } from '../../query'; import { IDataPluginServices } from '../../types'; import { TimeRange, IIndexPattern } from '../../../common'; import { FilterBar } from '../filter_bar/filter_bar'; diff --git a/src/plugins/data/public/ui/typeahead/index.tsx b/src/plugins/data/public/ui/typeahead/index.tsx index 103580875151b..fb565d2711f64 100644 --- a/src/plugins/data/public/ui/typeahead/index.tsx +++ b/src/plugins/data/public/ui/typeahead/index.tsx @@ -7,12 +7,13 @@ */ import React from 'react'; -import type { SuggestionsComponentProps } from './suggestions_component'; const Fallback = () =>
; const LazySuggestionsComponent = React.lazy(() => import('./suggestions_component')); -export const SuggestionsComponent = (props: SuggestionsComponentProps) => ( +export const SuggestionsComponent = ( + props: React.ComponentProps +) => ( }> diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx index 6bc91619fe868..f7d6e2c3d6403 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx @@ -19,8 +19,7 @@ import { } from './constants'; import { SuggestionOnClick } from './types'; -// @internal -export interface SuggestionsComponentProps { +interface SuggestionsComponentProps { index: number | null; onClick: SuggestionOnClick; onMouseEnter: (index: number) => void; diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index b59756ef1e90e..3262ee70dff86 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -173,56 +173,74 @@ exports[`Inspector Data View component should render empty state 1`] = ` } > -
- -

- - No data available - -

-
- - -
- - -
-

- +

- The element did not provide any data. - -

+ + No data available + +

+ + + + +
+ + +
+

+ + The element did not provide any data. + +

+
+
+ +
- -
-
-
+
+
+
+ diff --git a/src/plugins/data/server/config_deprecations.test.ts b/src/plugins/data/server/config_deprecations.test.ts index 6c09b060aa763..3df1ea9119292 100644 --- a/src/plugins/data/server/config_deprecations.test.ts +++ b/src/plugins/data/server/config_deprecations.test.ts @@ -50,7 +50,7 @@ describe('Config Deprecations', () => { }, }; const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); - expect(migrated.kibana.autocompleteTerminateAfter).not.toBeDefined(); + expect(migrated.kibana?.autocompleteTerminateAfter).not.toBeDefined(); expect(migrated.data.autocomplete.valueSuggestions.terminateAfter).toEqual(123); expect(messages).toMatchInlineSnapshot(` Array [ @@ -66,7 +66,7 @@ describe('Config Deprecations', () => { }, }; const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); - expect(migrated.kibana.autocompleteTimeout).not.toBeDefined(); + expect(migrated.kibana?.autocompleteTimeout).not.toBeDefined(); expect(migrated.data.autocomplete.valueSuggestions.timeout).toEqual(123); expect(messages).toMatchInlineSnapshot(` Array [ diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index cb52500e78f94..74b4edde21ae0 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -11,7 +11,7 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { PluginStart as DataViewsServerPluginStart } from 'src/plugins/data_views/server'; import { ConfigSchema } from '../config'; -import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; +import type { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; import { SearchService } from './search/search_service'; import { QueryService } from './query/query_service'; import { ScriptsService } from './scripts'; @@ -21,7 +21,7 @@ import { AutocompleteService } from './autocomplete'; import { FieldFormatsSetup, FieldFormatsStart } from '../../field_formats/server'; import { getUiSettings } from './ui_settings'; -export interface DataEnhancements { +interface DataEnhancements { search: SearchEnhancements; } diff --git a/src/plugins/data/server/query/route_handler_context.test.ts b/src/plugins/data/server/query/route_handler_context.test.ts index cc7686a06cb67..f8c14d59e0f85 100644 --- a/src/plugins/data/server/query/route_handler_context.test.ts +++ b/src/plugins/data/server/query/route_handler_context.test.ts @@ -7,12 +7,8 @@ */ import { coreMock } from '../../../../core/server/mocks'; -import { - DATA_VIEW_SAVED_OBJECT_TYPE, - FilterStateStore, - SavedObject, - SavedQueryAttributes, -} from '../../common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE, FilterStateStore } from '../../common'; +import type { SavedObject, SavedQueryAttributes } from '../../common'; import { registerSavedQueryRouteHandlerContext } from './route_handler_context'; import { SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server'; diff --git a/src/plugins/data/server/search/mocks.ts b/src/plugins/data/server/search/mocks.ts index f358cd78d8f90..bca01c6a15d55 100644 --- a/src/plugins/data/server/search/mocks.ts +++ b/src/plugins/data/server/search/mocks.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ISearchSetup, ISearchStart } from './types'; +import type { ISearchSetup, ISearchStart } from './types'; import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; import { searchSourceMock } from './search_source/mocks'; diff --git a/src/plugins/data/server/search/routes/bsearch.ts b/src/plugins/data/server/search/routes/bsearch.ts index 9a15f84687f43..314de4254851f 100644 --- a/src/plugins/data/server/search/routes/bsearch.ts +++ b/src/plugins/data/server/search/routes/bsearch.ts @@ -14,7 +14,7 @@ import { IKibanaSearchResponse, ISearchOptionsSerializable, } from '../../../common/search'; -import { ISearchStart } from '../types'; +import type { ISearchStart } from '../types'; export function registerBsearchRoute( bfetch: BfetchServerSetup, diff --git a/src/plugins/data/server/search/search_service.test.ts b/src/plugins/data/server/search/search_service.test.ts index d8fc180ea1781..f449018612cef 100644 --- a/src/plugins/data/server/search/search_service.test.ts +++ b/src/plugins/data/server/search/search_service.test.ts @@ -17,7 +17,7 @@ import { createIndexPatternsStartMock } from '../data_views/mocks'; import { SearchService, SearchServiceSetupDependencies } from './search_service'; import { bfetchPluginMock } from '../../../bfetch/server/mocks'; import { of } from 'rxjs'; -import { +import type { IEsSearchRequest, IEsSearchResponse, IScopedSearchClient, @@ -25,8 +25,8 @@ import { ISearchSessionService, ISearchStart, ISearchStrategy, - NoSearchIdInSessionError, } from '.'; +import { NoSearchIdInSessionError } from '.'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { expressionsPluginMock } from '../../../expressions/public/mocks'; import { createSearchSessionsClientMock } from './mocks'; diff --git a/src/plugins/data/server/search/search_source/mocks.ts b/src/plugins/data/server/search/search_source/mocks.ts index c990597d9a217..6ae30e0391000 100644 --- a/src/plugins/data/server/search/search_source/mocks.ts +++ b/src/plugins/data/server/search/search_source/mocks.ts @@ -10,7 +10,7 @@ import type { MockedKeys } from '@kbn/utility-types/jest'; import { KibanaRequest } from 'src/core/server'; import { searchSourceCommonMock } from '../../../common/search/search_source/mocks'; -import { ISearchStart } from '../types'; +import type { ISearchStart } from '../types'; function createStartContract(): MockedKeys { return { diff --git a/src/plugins/data/server/search/session/mocks.ts b/src/plugins/data/server/search/session/mocks.ts index b55292e4ac469..047f12df822c4 100644 --- a/src/plugins/data/server/search/session/mocks.ts +++ b/src/plugins/data/server/search/session/mocks.ts @@ -7,7 +7,7 @@ */ import moment from 'moment'; -import { IScopedSearchSessionsClient } from './types'; +import type { IScopedSearchSessionsClient } from './types'; import { SearchSessionsConfigSchema } from '../../../config'; export function createSearchSessionsClientMock(): jest.Mocked< diff --git a/src/plugins/data/server/search/strategies/ese_search/response_utils.ts b/src/plugins/data/server/search/strategies/ese_search/response_utils.ts index 0a92c95dac615..c9390a1b381d5 100644 --- a/src/plugins/data/server/search/strategies/ese_search/response_utils.ts +++ b/src/plugins/data/server/search/strategies/ese_search/response_utils.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { AsyncSearchResponse } from './types'; +import type { AsyncSearchResponse } from './types'; import { getTotalLoaded } from '../es_search'; /** diff --git a/src/plugins/data/server/search/types.ts b/src/plugins/data/server/search/types.ts index 026ff9139d932..b2e28eec40c09 100644 --- a/src/plugins/data/server/search/types.ts +++ b/src/plugins/data/server/search/types.ts @@ -26,7 +26,7 @@ import { } from '../../common/search'; import { AggsSetup, AggsStart } from './aggs'; import { SearchUsage } from './collectors'; -import { IScopedSearchSessionsClient, ISearchSessionService } from './session'; +import type { IScopedSearchSessionsClient, ISearchSessionService } from './session'; export interface SearchEnhancements { sessionService: ISearchSessionService; @@ -123,9 +123,6 @@ export interface ISearchStart< export type SearchRequestHandlerContext = IScopedSearchClient; -/** - * @internal - */ export interface DataRequestHandlerContext extends RequestHandlerContext { search: SearchRequestHandlerContext; } diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap index fb463d0a5fb18..8b6e0a1682750 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap @@ -116,7 +116,7 @@ exports[`IndexedFieldsTable IndexedFieldsTable with rollup index pattern should "isUserEditable": false, "kbnType": undefined, "name": "conflictingField", - "type": "conflict", + "type": "keyword, long", }, Object { "displayName": "amount", @@ -274,7 +274,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` "isUserEditable": false, "kbnType": undefined, "name": "conflictingField", - "type": "conflict", + "type": "keyword, long", }, Object { "displayName": "amount", diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/__snapshots__/table.test.tsx.snap b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/__snapshots__/table.test.tsx.snap index 100100106127b..2b6cf62baf221 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/__snapshots__/table.test.tsx.snap +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/__snapshots__/table.test.tsx.snap @@ -107,6 +107,7 @@ exports[`Table render name 2`] = ` exports[`Table should render conflicting type 1`] = ` + text, long `; +exports[`Table should render mixed, non-conflicting type 1`] = ` + + keyword, constant_keyword + +`; + exports[`Table should render normal field name 1`] = ` Elastic diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx index ec18665ccbaf3..dd78b00f9775e 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx @@ -126,7 +126,7 @@ describe('Table', () => { const tableCell = shallow( renderTable() .prop('columns')[1] - .render('conflict', { + .render('text, long', { kbnType: 'conflict', conflictDescriptions: { keyword: ['index_a'], long: ['index_b'] }, }) @@ -134,6 +134,15 @@ describe('Table', () => { expect(tableCell).toMatchSnapshot(); }); + test('should render mixed, non-conflicting type', () => { + const tableCell = shallow( + renderTable().prop('columns')[1].render('keyword, constant_keyword', { + kbnType: 'string', + }) + ); + expect(tableCell).toMatchSnapshot(); + }); + test('should allow edits', () => { const editField = jest.fn(); diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.tsx index e08b153f0b262..6a82d0380629c 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.tsx @@ -349,9 +349,11 @@ export class Table extends PureComponent { } renderFieldType(type: string, field: IndexedFieldItem) { + const conflictDescription = + field.conflictDescriptions && field.conflictDescriptions[field.name]; return ( - {type !== 'conflict' ? type : ''} + {type === 'conflict' && conflictDescription ? '' : type} {field.conflictDescriptions ? getConflictBtn(field.name, field.conflictDescriptions, this.props.openModal) : ''} diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx index 1e0d36f465be5..a72c87655fd63 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx @@ -7,7 +7,6 @@ */ import React, { Component } from 'react'; -import { i18n } from '@kbn/i18n'; import { createSelector } from 'reselect'; import { OverlayStart } from 'src/core/public'; import { IndexPatternField, IndexPattern } from '../../../../../../plugins/data/public'; @@ -68,25 +67,12 @@ class IndexedFields extends Component) => f.value); const fieldWildcardMatch = fieldWildcardMatcher(sourceFilters || []); - const getDisplayEsType = (arr: string[]): string => { - const length = arr.length; - if (length < 1) { - return ''; - } - if (length > 1) { - return i18n.translate('indexPatternManagement.editIndexPattern.fields.conflictType', { - defaultMessage: 'conflict', - }); - } - return arr[0]; - }; - return ( (fields && fields.map((field) => { return { ...field.spec, - type: getDisplayEsType(field.esTypes || []), + type: field.esTypes?.join(', ') || '', kbnType: field.type, displayName: field.displayName, format: indexPattern.getFormatterForFieldNoDefault(field.name)?.type?.title || '', @@ -117,7 +103,14 @@ class IndexedFields extends Component field.type === indexedFieldTypeFilter); + // match conflict fields + fields = fields.filter((field) => { + if (indexedFieldTypeFilter === 'conflict' && field.kbnType === 'conflict') { + return true; + } + // match one of multiple types on a field + return field.esTypes?.length && field.esTypes?.indexOf(indexedFieldTypeFilter) !== -1; + }); } return fields; diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx index c79871dbc8d71..b5940fa8d1bb0 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx @@ -93,13 +93,6 @@ export function Tabs({ const closeEditorHandler = useRef<() => void | undefined>(); const { DeleteRuntimeFieldProvider } = dataViewFieldEditor; - const conflict = i18n.translate( - 'indexPatternManagement.editIndexPattern.fieldTypes.conflictType', - { - defaultMessage: 'conflict', - } - ); - const refreshFilters = useCallback(() => { const tempIndexedFieldTypes: string[] = []; const tempScriptedFieldLanguages: string[] = []; @@ -109,8 +102,13 @@ export function Tabs({ tempScriptedFieldLanguages.push(field.lang); } } else { + // for conflicted fields, add conflict as a type + if (field.type === 'conflict') { + tempIndexedFieldTypes.push('conflict'); + } if (field.esTypes) { - tempIndexedFieldTypes.push(field.esTypes.length === 1 ? field.esTypes[0] : conflict); + // add all types, may be multiple + field.esTypes.forEach((item) => tempIndexedFieldTypes.push(item)); } } }); @@ -119,7 +117,7 @@ export function Tabs({ setScriptedFieldLanguages( convertToEuiSelectOption(tempScriptedFieldLanguages, 'scriptedFieldLanguages') ); - }, [indexPattern, conflict]); + }, [indexPattern]); const closeFieldEditor = useCallback(() => { if (closeEditorHandler.current) { diff --git a/src/plugins/data_views/server/fetcher/index_patterns_fetcher.test.ts b/src/plugins/data_views/server/fetcher/index_patterns_fetcher.test.ts index 1a8b705480258..f55609d75a066 100644 --- a/src/plugins/data_views/server/fetcher/index_patterns_fetcher.test.ts +++ b/src/plugins/data_views/server/fetcher/index_patterns_fetcher.test.ts @@ -32,9 +32,15 @@ describe('Index Pattern Fetcher - server', () => { indexPatterns = new IndexPatternsFetcher(esClient); }); it('Removes pattern without matching indices', async () => { + // first field caps request returns empty const result = await indexPatterns.validatePatternListActive(patternList); expect(result).toEqual(['b', 'c']); }); + it('Keeps matching and negating patterns', async () => { + // first field caps request returns empty + const result = await indexPatterns.validatePatternListActive(['-a', 'b', 'c']); + expect(result).toEqual(['-a', 'c']); + }); it('Returns all patterns when all match indices', async () => { esClient = { fieldCaps: jest.fn().mockResolvedValue(response), diff --git a/src/plugins/data_views/server/fetcher/index_patterns_fetcher.ts b/src/plugins/data_views/server/fetcher/index_patterns_fetcher.ts index c054d547e956f..bceefac22e0f0 100644 --- a/src/plugins/data_views/server/fetcher/index_patterns_fetcher.ts +++ b/src/plugins/data_views/server/fetcher/index_patterns_fetcher.ts @@ -133,6 +133,10 @@ export class IndexPatternsFetcher { const result = await Promise.all( patternList .map(async (index) => { + // perserve negated patterns + if (index.startsWith('-')) { + return true; + } const searchResponse = await this.elasticsearchClient.fieldCaps({ index, fields: '_id', diff --git a/src/plugins/dev_tools/kibana.json b/src/plugins/dev_tools/kibana.json index 75a1e82f1d910..9b2ae8a3f995f 100644 --- a/src/plugins/dev_tools/kibana.json +++ b/src/plugins/dev_tools/kibana.json @@ -7,5 +7,6 @@ "name": "Stack Management", "githubTeam": "kibana-stack-management" }, - "requiredPlugins": ["urlForwarding"] + "requiredPlugins": ["urlForwarding"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index a4fdaf28e0eb4..dc72cfda790d4 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -16,6 +16,7 @@ import { i18n } from '@kbn/i18n'; import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme'; import { ApplicationStart, ChromeStart, ScopedHistory, CoreTheme } from 'src/core/public'; +import { KibanaThemeProvider } from '../../kibana_react/public'; import type { DocTitleService, BreadcrumbService } from './services'; import { DevToolApp } from './dev_tool'; @@ -177,32 +178,34 @@ export function renderApp( ReactDOM.render( - - - {devTools - // Only create routes for devtools that are not disabled - .filter((devTool) => !devTool.isDisabled()) - .map((devTool) => ( - ( - - )} - /> - ))} - - - - - + + + + {devTools + // Only create routes for devtools that are not disabled + .filter((devTool) => !devTool.isDisabled()) + .map((devTool) => ( + ( + + )} + /> + ))} + + + + + + , element ); diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 6a90ed42417e6..337d44227139e 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -78,7 +78,7 @@ export const discoverServiceMock = { http: { basePath: '/', }, - indexPatternFieldEditor: { + dataViewFieldEditor: { openEditor: jest.fn(), userPermissions: { editIndexPattern: jest.fn(), @@ -97,4 +97,5 @@ export const discoverServiceMock = { storage: { get: jest.fn(), }, + addBasePath: jest.fn(), } as unknown as DiscoverServices; diff --git a/src/plugins/discover/public/application/context/context_app_route.tsx b/src/plugins/discover/public/application/context/context_app_route.tsx index dfc318021b93e..80feea833ec94 100644 --- a/src/plugins/discover/public/application/context/context_app_route.tsx +++ b/src/plugins/discover/public/application/context/context_app_route.tsx @@ -15,6 +15,7 @@ import { ContextApp } from './context_app'; import { getRootBreadcrumbs } from '../../utils/breadcrumbs'; import { LoadingIndicator } from '../../components/common/loading_indicator'; import { useIndexPattern } from '../../utils/use_index_pattern'; +import { useMainRouteBreadcrumb } from '../../utils/use_navigation_props'; export interface ContextAppProps { /** @@ -33,17 +34,18 @@ export function ContextAppRoute(props: ContextAppProps) { const { chrome } = services; const { indexPatternId, id } = useParams(); + const breadcrumb = useMainRouteBreadcrumb(); useEffect(() => { chrome.setBreadcrumbs([ - ...getRootBreadcrumbs(), + ...getRootBreadcrumbs(breadcrumb), { text: i18n.translate('discover.context.breadcrumb', { defaultMessage: 'Surrounding documents', }), }, ]); - }, [chrome]); + }, [chrome, breadcrumb]); const { indexPattern, error } = useIndexPattern(services.indexPatterns, indexPatternId); diff --git a/src/plugins/discover/public/application/doc/single_doc_route.tsx b/src/plugins/discover/public/application/doc/single_doc_route.tsx index e5ddb784b9080..0a5cc3a8a82b6 100644 --- a/src/plugins/discover/public/application/doc/single_doc_route.tsx +++ b/src/plugins/discover/public/application/doc/single_doc_route.tsx @@ -14,6 +14,7 @@ import { getRootBreadcrumbs } from '../../utils/breadcrumbs'; import { Doc } from './components/doc'; import { LoadingIndicator } from '../../components/common/loading_indicator'; import { useIndexPattern } from '../../utils/use_index_pattern'; +import { useMainRouteBreadcrumb } from '../../utils/use_navigation_props'; export interface SingleDocRouteProps { /** @@ -36,18 +37,19 @@ export function SingleDocRoute(props: SingleDocRouteProps) { const { chrome, timefilter } = services; const { indexPatternId, index } = useParams(); + const breadcrumb = useMainRouteBreadcrumb(); const query = useQuery(); const docId = query.get('id') || ''; useEffect(() => { chrome.setBreadcrumbs([ - ...getRootBreadcrumbs(), + ...getRootBreadcrumbs(breadcrumb), { text: `${index}#${docId}`, }, ]); - }, [chrome, index, docId]); + }, [chrome, index, docId, breadcrumb]); useEffect(() => { timefilter.disableAutoRefreshSelector(); diff --git a/src/plugins/discover/public/application/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap b/src/plugins/discover/public/application/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap index 6cb6a15aa0f66..17d414215af55 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap +++ b/src/plugins/discover/public/application/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap @@ -653,13 +653,13 @@ exports[`Discover IndexPattern Management renders correctly 1`] = ` "navigateToApp": [MockFunction], }, }, - "history": [Function], - "indexPatternFieldEditor": Object { + "dataViewFieldEditor": Object { "openEditor": [MockFunction], "userPermissions": Object { "editIndexPattern": [Function], }, }, + "history": [Function], "uiSettings": Object { "get": [Function], }, diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx index c5b1f4d2612d6..4132e4fb1b9b8 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx @@ -39,7 +39,7 @@ const mockServices = { } }, }, - indexPatternFieldEditor: { + dataViewFieldEditor: { openEditor: jest.fn(), userPermissions: { editIndexPattern: () => { diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx index 9353073e7fad6..7fbb518ca3034 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx @@ -33,14 +33,13 @@ export interface DiscoverIndexPatternManagementProps { } export function DiscoverIndexPatternManagement(props: DiscoverIndexPatternManagementProps) { - const { indexPatternFieldEditor, core } = props.services; + const { dataViewFieldEditor, core } = props.services; const { useNewFieldsApi, selectedIndexPattern, editField } = props; - const indexPatternFieldEditPermission = - indexPatternFieldEditor?.userPermissions.editIndexPattern(); - const canEditIndexPatternField = !!indexPatternFieldEditPermission && useNewFieldsApi; + const dataViewEditPermission = dataViewFieldEditor?.userPermissions.editIndexPattern(); + const canEditDataViewField = !!dataViewEditPermission && useNewFieldsApi; const [isAddIndexPatternFieldPopoverOpen, setIsAddIndexPatternFieldPopoverOpen] = useState(false); - if (!useNewFieldsApi || !selectedIndexPattern || !canEditIndexPatternField) { + if (!useNewFieldsApi || !selectedIndexPattern || !canEditDataViewField) { return null; } diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx index 78aee49d1b288..ea7b6fd31923e 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx @@ -109,10 +109,9 @@ export function DiscoverSidebarComponent({ }: DiscoverSidebarProps) { const [fields, setFields] = useState(null); - const { indexPatternFieldEditor } = services; - const indexPatternFieldEditPermission = - indexPatternFieldEditor?.userPermissions.editIndexPattern(); - const canEditIndexPatternField = !!indexPatternFieldEditPermission && useNewFieldsApi; + const { dataViewFieldEditor } = services; + const dataViewFieldEditPermission = dataViewFieldEditor?.userPermissions.editIndexPattern(); + const canEditDataViewField = !!dataViewFieldEditPermission && useNewFieldsApi; const [scrollContainer, setScrollContainer] = useState(null); const [fieldsToRender, setFieldsToRender] = useState(FIELDS_PER_PAGE); const [fieldsPerPage, setFieldsPerPage] = useState(FIELDS_PER_PAGE); @@ -243,9 +242,9 @@ export function DiscoverSidebarComponent({ const deleteField = useMemo( () => - canEditIndexPatternField && selectedIndexPattern + canEditDataViewField && selectedIndexPattern ? async (fieldName: string) => { - const ref = indexPatternFieldEditor.openDeleteModal({ + const ref = dataViewFieldEditor.openDeleteModal({ ctx: { dataView: selectedIndexPattern, }, @@ -264,11 +263,11 @@ export function DiscoverSidebarComponent({ : undefined, [ selectedIndexPattern, - canEditIndexPatternField, + canEditDataViewField, setFieldEditorRef, closeFlyout, onEditRuntimeField, - indexPatternFieldEditor, + dataViewFieldEditor, ] ); @@ -413,8 +412,8 @@ export function DiscoverSidebarComponent({ selected={true} trackUiMetric={trackUiMetric} multiFields={multiFields?.get(field.name)} - onEditField={canEditIndexPatternField ? editField : undefined} - onDeleteField={canEditIndexPatternField ? deleteField : undefined} + onEditField={canEditDataViewField ? editField : undefined} + onDeleteField={canEditDataViewField ? deleteField : undefined} showFieldStats={showFieldStats} /> @@ -473,8 +472,8 @@ export function DiscoverSidebarComponent({ getDetails={getDetailsByField} trackUiMetric={trackUiMetric} multiFields={multiFields?.get(field.name)} - onEditField={canEditIndexPatternField ? editField : undefined} - onDeleteField={canEditIndexPatternField ? deleteField : undefined} + onEditField={canEditDataViewField ? editField : undefined} + onDeleteField={canEditDataViewField ? deleteField : undefined} showFieldStats={showFieldStats} /> @@ -502,8 +501,8 @@ export function DiscoverSidebarComponent({ getDetails={getDetailsByField} trackUiMetric={trackUiMetric} multiFields={multiFields?.get(field.name)} - onEditField={canEditIndexPatternField ? editField : undefined} - onDeleteField={canEditIndexPatternField ? deleteField : undefined} + onEditField={canEditDataViewField ? editField : undefined} + onDeleteField={canEditDataViewField ? deleteField : undefined} showFieldStats={showFieldStats} /> diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx index a4e84bd831619..6316369ff4c6f 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx @@ -180,17 +180,17 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) setIsFlyoutVisible(false); }, []); - const { indexPatternFieldEditor } = props.services; + const { dataViewFieldEditor } = props.services; const editField = useCallback( (fieldName?: string) => { const indexPatternFieldEditPermission = - indexPatternFieldEditor?.userPermissions.editIndexPattern(); + dataViewFieldEditor?.userPermissions.editIndexPattern(); const canEditIndexPatternField = !!indexPatternFieldEditPermission && useNewFieldsApi; if (!canEditIndexPatternField || !selectedIndexPattern) { return; } - const ref = indexPatternFieldEditor.openEditor({ + const ref = dataViewFieldEditor.openEditor({ ctx: { dataView: selectedIndexPattern, }, @@ -208,7 +208,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) }, [ closeFlyout, - indexPatternFieldEditor, + dataViewFieldEditor, selectedIndexPattern, setFieldEditorRef, onEditRuntimeField, diff --git a/src/plugins/discover/public/application/main/utils/fetch_all.test.ts b/src/plugins/discover/public/application/main/utils/fetch_all.test.ts index 9f17054de18d4..a2dae5cc99b7d 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.test.ts @@ -6,23 +6,66 @@ * Side Public License, v 1. */ import { FetchStatus } from '../../types'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { reduce } from 'rxjs/operators'; +import { SearchSource } from '../../../../../data/common'; import { RequestAdapter } from '../../../../../inspector'; import { savedSearchMock } from '../../../__mocks__/saved_search'; import { ReduxLikeStateContainer } from '../../../../../kibana_utils/common'; import { AppState } from '../services/discover_state'; import { discoverServiceMock } from '../../../__mocks__/services'; import { fetchAll } from './fetch_all'; +import { + DataChartsMessage, + DataDocumentsMsg, + DataMainMsg, + DataTotalHitsMsg, + SavedSearchData, +} from './use_saved_search'; + +import { fetchDocuments } from './fetch_documents'; +import { fetchChart } from './fetch_chart'; +import { fetchTotalHits } from './fetch_total_hits'; + +jest.mock('./fetch_documents', () => ({ + fetchDocuments: jest.fn().mockResolvedValue([]), +})); + +jest.mock('./fetch_chart', () => ({ + fetchChart: jest.fn(), +})); + +jest.mock('./fetch_total_hits', () => ({ + fetchTotalHits: jest.fn(), +})); + +const mockFetchDocuments = fetchDocuments as unknown as jest.MockedFunction; +const mockFetchTotalHits = fetchTotalHits as unknown as jest.MockedFunction; +const mockFetchChart = fetchChart as unknown as jest.MockedFunction; + +function subjectCollector(subject: Subject): () => Promise { + const promise = subject + .pipe(reduce((history, value) => history.concat([value]), [] as T[])) + .toPromise(); + + return () => { + subject.complete(); + return promise; + }; +} describe('test fetchAll', () => { - test('changes of fetchStatus when starting with FetchStatus.UNINITIALIZED', async (done) => { - const subjects = { - main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + let subjects: SavedSearchData; + let deps: Parameters[3]; + let searchSource: SearchSource; + beforeEach(() => { + subjects = { + main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), }; - const deps = { + deps = { appStateContainer: { getState: () => { return { interval: 'auto' }; @@ -31,29 +74,126 @@ describe('test fetchAll', () => { abortController: new AbortController(), data: discoverServiceMock.data, inspectorAdapters: { requests: new RequestAdapter() }, - onResults: jest.fn(), searchSessionId: '123', initialFetchStatus: FetchStatus.UNINITIALIZED, useNewFieldsApi: true, services: discoverServiceMock, }; + searchSource = savedSearchMock.searchSource.createChild(); + + mockFetchDocuments.mockReset().mockResolvedValue([]); + mockFetchTotalHits.mockReset().mockResolvedValue(42); + mockFetchChart + .mockReset() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValue({ totalHits: 42, chartData: {} as any, bucketInterval: {} }); + }); + test('changes of fetchStatus when starting with FetchStatus.UNINITIALIZED', async () => { const stateArr: FetchStatus[] = []; subjects.main$.subscribe((value) => stateArr.push(value.fetchStatus)); - const parentSearchSource = savedSearchMock.searchSource; - const childSearchSource = parentSearchSource.createChild(); - - fetchAll(subjects, childSearchSource, false, deps).subscribe({ - complete: () => { - expect(stateArr).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.COMPLETE, - ]); - done(); - }, - }); + await fetchAll(subjects, searchSource, false, deps); + + expect(stateArr).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.COMPLETE, + ]); + }); + + test('emits loading and documents on documents$ correctly', async () => { + const collect = subjectCollector(subjects.documents$); + const hits = [ + { _id: '1', _index: 'logs' }, + { _id: '2', _index: 'logs' }, + ]; + mockFetchDocuments.mockResolvedValue(hits); + await fetchAll(subjects, searchSource, false, deps); + expect(await collect()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { fetchStatus: FetchStatus.LOADING }, + { fetchStatus: FetchStatus.COMPLETE, result: hits }, + ]); + }); + + test('emits loading and hit count on totalHits$ correctly', async () => { + const collect = subjectCollector(subjects.totalHits$); + const hits = [ + { _id: '1', _index: 'logs' }, + { _id: '2', _index: 'logs' }, + ]; + searchSource.getField('index')!.isTimeBased = () => false; + mockFetchDocuments.mockResolvedValue(hits); + mockFetchTotalHits.mockResolvedValue(42); + await fetchAll(subjects, searchSource, false, deps); + expect(await collect()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { fetchStatus: FetchStatus.LOADING }, + { fetchStatus: FetchStatus.PARTIAL, result: 2 }, + { fetchStatus: FetchStatus.COMPLETE, result: 42 }, + ]); + }); + + test('emits loading and chartData on charts$ correctly', async () => { + const collect = subjectCollector(subjects.charts$); + searchSource.getField('index')!.isTimeBased = () => true; + await fetchAll(subjects, searchSource, false, deps); + expect(await collect()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { fetchStatus: FetchStatus.LOADING }, + { fetchStatus: FetchStatus.COMPLETE, bucketInterval: {}, chartData: {} }, + ]); + }); + + test('should use charts query to fetch total hit count when chart is visible', async () => { + const collect = subjectCollector(subjects.totalHits$); + searchSource.getField('index')!.isTimeBased = () => true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockFetchChart.mockResolvedValue({ bucketInterval: {}, chartData: {} as any, totalHits: 32 }); + await fetchAll(subjects, searchSource, false, deps); + expect(await collect()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { fetchStatus: FetchStatus.LOADING }, + { fetchStatus: FetchStatus.PARTIAL, result: 0 }, // From documents query + { fetchStatus: FetchStatus.COMPLETE, result: 32 }, + ]); + expect(mockFetchTotalHits).not.toHaveBeenCalled(); + }); + + test('should only fail totalHits$ query not main$ for error from that query', async () => { + const collectTotalHits = subjectCollector(subjects.totalHits$); + const collectMain = subjectCollector(subjects.main$); + searchSource.getField('index')!.isTimeBased = () => false; + mockFetchTotalHits.mockRejectedValue({ msg: 'Oh noes!' }); + mockFetchDocuments.mockResolvedValue([{ _id: '1', _index: 'logs' }]); + await fetchAll(subjects, searchSource, false, deps); + expect(await collectTotalHits()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { fetchStatus: FetchStatus.LOADING }, + { fetchStatus: FetchStatus.PARTIAL, result: 1 }, + { fetchStatus: FetchStatus.ERROR, error: { msg: 'Oh noes!' } }, + ]); + expect(await collectMain()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { fetchStatus: FetchStatus.LOADING }, + { fetchStatus: FetchStatus.PARTIAL }, + { fetchStatus: FetchStatus.COMPLETE, foundDocuments: true }, + ]); + }); + + test('should not set COMPLETE if an ERROR has been set on main$', async () => { + const collectMain = subjectCollector(subjects.main$); + searchSource.getField('index')!.isTimeBased = () => false; + mockFetchDocuments.mockRejectedValue({ msg: 'This query failed' }); + await fetchAll(subjects, searchSource, false, deps); + expect(await collectMain()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { fetchStatus: FetchStatus.LOADING }, + { fetchStatus: FetchStatus.PARTIAL }, // From totalHits query + { fetchStatus: FetchStatus.ERROR, error: { msg: 'This query failed' } }, + // Here should be no COMPLETE coming anymore + ]); }); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_all.ts b/src/plugins/discover/public/application/main/utils/fetch_all.ts index 471616c9d4261..29279152ca321 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.ts @@ -5,11 +5,11 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { forkJoin, of } from 'rxjs'; import { sendCompleteMsg, sendErrorMsg, sendLoadingMsg, + sendNoResultsFoundMsg, sendPartialMsg, sendResetMsg, } from './use_saved_search_messages'; @@ -23,11 +23,25 @@ import { Adapters } from '../../../../../inspector'; import { AppState } from '../services/discover_state'; import { FetchStatus } from '../../types'; import { DataPublicPluginStart } from '../../../../../data/public'; -import { SavedSearchData } from './use_saved_search'; +import { + DataCharts$, + DataDocuments$, + DataMain$, + DataTotalHits$, + SavedSearchData, +} from './use_saved_search'; import { DiscoverServices } from '../../../build_services'; import { ReduxLikeStateContainer } from '../../../../../kibana_utils/common'; import { DataViewType } from '../../../../../data_views/common'; +/** + * This function starts fetching all required queries in Discover. This will be the query to load the individual + * documents, and depending on whether a chart is shown either the aggregation query to load the chart data + * or a query to retrieve just the total hits. + * + * This method returns a promise, which will resolve (without a value), as soon as all queries that have been started + * have been completed (failed or successfully). + */ export function fetchAll( dataSubjects: SavedSearchData, searchSource: ISearchSource, @@ -42,57 +56,137 @@ export function fetchAll( services: DiscoverServices; useNewFieldsApi: boolean; } -) { +): Promise { const { initialFetchStatus, appStateContainer, services, useNewFieldsApi, data } = fetchDeps; - const indexPattern = searchSource.getField('index')!; + /** + * Method to create a an error handler that will forward the received error + * to the specified subjects. It will ignore AbortErrors and will use the data + * plugin to show a toast for the error (e.g. allowing better insights into shard failures). + */ + const sendErrorTo = ( + ...errorSubjects: Array + ) => { + return (error: Error) => { + if (error instanceof Error && error.name === 'AbortError') { + return; + } - if (reset) { - sendResetMsg(dataSubjects, initialFetchStatus); - } + data.search.showError(error); + errorSubjects.forEach((subject) => sendErrorMsg(subject, error)); + }; + }; - sendLoadingMsg(dataSubjects.main$); - - const { hideChart, sort } = appStateContainer.getState(); - // Update the base searchSource, base for all child fetches - updateSearchSource(searchSource, false, { - indexPattern, - services, - sort: sort as SortOrder[], - useNewFieldsApi, - }); - - const subFetchDeps = { - ...fetchDeps, - onResults: (foundDocuments: boolean) => { - if (!foundDocuments) { - sendCompleteMsg(dataSubjects.main$, foundDocuments); - } else { + try { + const indexPattern = searchSource.getField('index')!; + + if (reset) { + sendResetMsg(dataSubjects, initialFetchStatus); + } + + const { hideChart, sort } = appStateContainer.getState(); + + // Update the base searchSource, base for all child fetches + updateSearchSource(searchSource, false, { + indexPattern, + services, + sort: sort as SortOrder[], + useNewFieldsApi, + }); + + // Mark all subjects as loading + sendLoadingMsg(dataSubjects.main$); + sendLoadingMsg(dataSubjects.documents$); + sendLoadingMsg(dataSubjects.totalHits$); + sendLoadingMsg(dataSubjects.charts$); + + const isChartVisible = + !hideChart && indexPattern.isTimeBased() && indexPattern.type !== DataViewType.ROLLUP; + + // Start fetching all required requests + const documents = fetchDocuments(searchSource.createCopy(), fetchDeps); + const charts = isChartVisible ? fetchChart(searchSource.createCopy(), fetchDeps) : undefined; + const totalHits = !isChartVisible + ? fetchTotalHits(searchSource.createCopy(), fetchDeps) + : undefined; + + /** + * This method checks the passed in hit count and will send a PARTIAL message to main$ + * if there are results, indicating that we have finished some of the requests that have been + * sent. If there are no results we already COMPLETE main$ with no results found, so Discover + * can show the "no results" screen. We know at that point, that the other query returning + * will neither carry any data, since there are no documents. + */ + const checkHitCount = (hitsCount: number) => { + if (hitsCount > 0) { sendPartialMsg(dataSubjects.main$); + } else { + sendNoResultsFoundMsg(dataSubjects.main$); } - }, - }; + }; - const isChartVisible = - !hideChart && indexPattern.isTimeBased() && indexPattern.type !== DataViewType.ROLLUP; - - const all = forkJoin({ - documents: fetchDocuments(dataSubjects, searchSource.createCopy(), subFetchDeps), - totalHits: !isChartVisible - ? fetchTotalHits(dataSubjects, searchSource.createCopy(), subFetchDeps) - : of(null), - chart: isChartVisible - ? fetchChart(dataSubjects, searchSource.createCopy(), subFetchDeps) - : of(null), - }); - - all.subscribe( - () => sendCompleteMsg(dataSubjects.main$, true), - (error) => { - if (error instanceof Error && error.name === 'AbortError') return; - data.search.showError(error); - sendErrorMsg(dataSubjects.main$, error); - } - ); - return all; + // Handle results of the individual queries and forward the results to the corresponding dataSubjects + + documents + .then((docs) => { + // If the total hits (or chart) query is still loading, emit a partial + // hit count that's at least our retrieved document count + if (dataSubjects.totalHits$.getValue().fetchStatus === FetchStatus.LOADING) { + dataSubjects.totalHits$.next({ + fetchStatus: FetchStatus.PARTIAL, + result: docs.length, + }); + } + + dataSubjects.documents$.next({ + fetchStatus: FetchStatus.COMPLETE, + result: docs, + }); + + checkHitCount(docs.length); + }) + // Only the document query should send its errors to main$, to cause the full Discover app + // to get into an error state. The other queries will not cause all of Discover to error out + // but their errors will be shown in-place (e.g. of the chart). + .catch(sendErrorTo(dataSubjects.documents$, dataSubjects.main$)); + + charts + ?.then((chart) => { + dataSubjects.totalHits$.next({ + fetchStatus: FetchStatus.COMPLETE, + result: chart.totalHits, + }); + + dataSubjects.charts$.next({ + fetchStatus: FetchStatus.COMPLETE, + chartData: chart.chartData, + bucketInterval: chart.bucketInterval, + }); + + checkHitCount(chart.totalHits); + }) + .catch(sendErrorTo(dataSubjects.charts$, dataSubjects.totalHits$)); + + totalHits + ?.then((hitCount) => { + dataSubjects.totalHits$.next({ fetchStatus: FetchStatus.COMPLETE, result: hitCount }); + checkHitCount(hitCount); + }) + .catch(sendErrorTo(dataSubjects.totalHits$)); + + // Return a promise that will resolve once all the requests have finished or failed + return Promise.allSettled([documents, charts, totalHits]).then(() => { + // Send a complete message to main$ once all queries are done and if main$ + // is not already in an ERROR state, e.g. because the document query has failed. + // This will only complete main$, if it hasn't already been completed previously + // by a query finding no results. + if (dataSubjects.main$.getValue().fetchStatus !== FetchStatus.ERROR) { + sendCompleteMsg(dataSubjects.main$); + } + }); + } catch (error) { + sendErrorMsg(dataSubjects.main$, error); + // We also want to return a resolved promise in an error case, since it just indicates we're done with querying. + return Promise.resolve(); + } } diff --git a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts index 5f57484aaa653..b8c2f643acae7 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts @@ -5,8 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { FetchStatus } from '../../types'; -import { BehaviorSubject, of, throwError as throwErrorRx } from 'rxjs'; +import { of, throwError as throwErrorRx } from 'rxjs'; import { RequestAdapter } from '../../../../../inspector'; import { savedSearchMockWithTimeField } from '../../../__mocks__/saved_search'; import { fetchChart, updateSearchSource } from './fetch_chart'; @@ -16,15 +15,6 @@ import { discoverServiceMock } from '../../../__mocks__/services'; import { calculateBounds, IKibanaSearchResponse } from '../../../../../data/common'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -function getDataSubjects() { - return { - main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - }; -} - describe('test fetchCharts', () => { test('updateSearchSource helper function', () => { const chartAggConfigs = updateSearchSource( @@ -61,8 +51,7 @@ describe('test fetchCharts', () => { `); }); - test('changes of fetchStatus when starting with FetchStatus.UNINITIALIZED', async (done) => { - const subjects = getDataSubjects(); + test('resolves with summarized chart data', async () => { const deps = { appStateContainer: { getState: () => { @@ -82,12 +71,6 @@ describe('test fetchCharts', () => { deps.data.query.timefilter.timefilter.calculateBounds = (timeRange) => calculateBounds(timeRange); - const stateArrChart: FetchStatus[] = []; - const stateArrHits: FetchStatus[] = []; - - subjects.charts$.subscribe((value) => stateArrChart.push(value.fetchStatus)); - subjects.totalHits$.subscribe((value) => stateArrHits.push(value.fetchStatus)); - savedSearchMockWithTimeField.searchSource.fetch$ = () => of({ id: 'Fjk5bndxTHJWU2FldVRVQ0tYR0VqOFEcRWtWNDhOdG5SUzJYcFhONVVZVTBJQToxMDMwOQ==', @@ -95,7 +78,7 @@ describe('test fetchCharts', () => { took: 2, timed_out: false, _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, - hits: { max_score: null, hits: [] }, + hits: { max_score: null, hits: [], total: 42 }, aggregations: { '2': { buckets: [ @@ -115,25 +98,13 @@ describe('test fetchCharts', () => { isRestored: false, } as unknown as IKibanaSearchResponse>); - fetchChart(subjects, savedSearchMockWithTimeField.searchSource, deps).subscribe({ - complete: () => { - expect(stateArrChart).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.COMPLETE, - ]); - expect(stateArrHits).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.COMPLETE, - ]); - done(); - }, - }); + const result = await fetchChart(savedSearchMockWithTimeField.searchSource, deps); + expect(result).toHaveProperty('totalHits', 42); + expect(result).toHaveProperty('bucketInterval.description', '0 milliseconds'); + expect(result).toHaveProperty('chartData'); }); - test('change of fetchStatus on fetch error', async (done) => { - const subjects = getDataSubjects(); + test('rejects promise on query failure', async () => { const deps = { appStateContainer: { getState: () => { @@ -149,26 +120,8 @@ describe('test fetchCharts', () => { savedSearchMockWithTimeField.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' }); - const stateArrChart: FetchStatus[] = []; - const stateArrHits: FetchStatus[] = []; - - subjects.charts$.subscribe((value) => stateArrChart.push(value.fetchStatus)); - subjects.totalHits$.subscribe((value) => stateArrHits.push(value.fetchStatus)); - - fetchChart(subjects, savedSearchMockWithTimeField.searchSource, deps).subscribe({ - error: () => { - expect(stateArrChart).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.ERROR, - ]); - expect(stateArrHits).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.ERROR, - ]); - done(); - }, + await expect(fetchChart(savedSearchMockWithTimeField.searchSource, deps)).rejects.toEqual({ + msg: 'Oh noes!', }); }); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_chart.ts b/src/plugins/discover/public/application/main/utils/fetch_chart.ts index 59377970acb12..7f74f693eb784 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_chart.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_chart.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import { i18n } from '@kbn/i18n'; -import { filter } from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; import { DataPublicPluginStart, isCompleteResponse, @@ -16,40 +16,36 @@ import { import { Adapters } from '../../../../../inspector'; import { getChartAggConfigs, getDimensions } from './index'; import { tabifyAggResponse } from '../../../../../data/common'; -import { buildPointSeriesData } from '../components/chart/point_series'; -import { FetchStatus } from '../../types'; -import { SavedSearchData } from './use_saved_search'; +import { buildPointSeriesData, Chart } from '../components/chart/point_series'; +import { TimechartBucketInterval } from './use_saved_search'; import { AppState } from '../services/discover_state'; import { ReduxLikeStateContainer } from '../../../../../kibana_utils/common'; -import { sendErrorMsg, sendLoadingMsg } from './use_saved_search_messages'; + +interface Result { + totalHits: number; + chartData: Chart; + bucketInterval: TimechartBucketInterval | undefined; +} export function fetchChart( - data$: SavedSearchData, searchSource: ISearchSource, { abortController, appStateContainer, data, inspectorAdapters, - onResults, searchSessionId, }: { abortController: AbortController; appStateContainer: ReduxLikeStateContainer; data: DataPublicPluginStart; inspectorAdapters: Adapters; - onResults: (foundDocuments: boolean) => void; searchSessionId: string; } -) { - const { charts$, totalHits$ } = data$; - +): Promise { const interval = appStateContainer.getState().interval ?? 'auto'; const chartAggConfigs = updateSearchSource(searchSource, interval, data); - sendLoadingMsg(charts$); - sendLoadingMsg(totalHits$); - const executionContext = { type: 'application', name: 'discover', @@ -74,15 +70,9 @@ export function fetchChart( }, executionContext, }) - .pipe(filter((res) => isCompleteResponse(res))); - - fetch$.subscribe( - (res) => { - try { - const totalHitsNr = res.rawResponse.hits.total as number; - totalHits$.next({ fetchStatus: FetchStatus.COMPLETE, result: totalHitsNr }); - onResults(totalHitsNr > 0); - + .pipe( + filter((res) => isCompleteResponse(res)), + map((res) => { const bucketAggConfig = chartAggConfigs.aggs[1]; const tabifiedData = tabifyAggResponse(chartAggConfigs, res.rawResponse); const dimensions = getDimensions(chartAggConfigs, data); @@ -90,27 +80,15 @@ export function fetchChart( ? bucketAggConfig?.buckets?.getInterval() : undefined; const chartData = buildPointSeriesData(tabifiedData, dimensions!); - charts$.next({ - fetchStatus: FetchStatus.COMPLETE, + return { chartData, bucketInterval, - }); - } catch (e) { - charts$.next({ - fetchStatus: FetchStatus.ERROR, - error: e, - }); - } - }, - (error) => { - if (error instanceof Error && error.name === 'AbortError') { - return; - } - sendErrorMsg(charts$, error); - sendErrorMsg(totalHits$, error); - } - ); - return fetch$; + totalHits: res.rawResponse.hits.total as number, + }; + }) + ); + + return fetch$.toPromise(); } export function updateSearchSource( diff --git a/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts b/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts index 291da255b5068..1342378f5a90b 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts @@ -6,74 +6,37 @@ * Side Public License, v 1. */ import { fetchDocuments } from './fetch_documents'; -import { FetchStatus } from '../../types'; -import { BehaviorSubject, throwError as throwErrorRx } from 'rxjs'; +import { throwError as throwErrorRx, of } from 'rxjs'; import { RequestAdapter } from '../../../../../inspector'; import { savedSearchMock } from '../../../__mocks__/saved_search'; import { discoverServiceMock } from '../../../__mocks__/services'; - -function getDataSubjects() { - return { - main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - }; -} +import { IKibanaSearchResponse } from 'src/plugins/data/common'; +import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; + +const getDeps = () => ({ + abortController: new AbortController(), + inspectorAdapters: { requests: new RequestAdapter() }, + onResults: jest.fn(), + searchSessionId: '123', + services: discoverServiceMock, +}); describe('test fetchDocuments', () => { - test('changes of fetchStatus are correct when starting with FetchStatus.UNINITIALIZED', async (done) => { - const subjects = getDataSubjects(); - const { documents$ } = subjects; - const deps = { - abortController: new AbortController(), - inspectorAdapters: { requests: new RequestAdapter() }, - onResults: jest.fn(), - searchSessionId: '123', - services: discoverServiceMock, - }; - - const stateArr: FetchStatus[] = []; - - documents$.subscribe((value) => stateArr.push(value.fetchStatus)); - - fetchDocuments(subjects, savedSearchMock.searchSource, deps).subscribe({ - complete: () => { - expect(stateArr).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.COMPLETE, - ]); - done(); - }, - }); + test('resolves with returned documents', async () => { + const hits = [ + { _id: '1', foo: 'bar' }, + { _id: '2', foo: 'baz' }, + ]; + savedSearchMock.searchSource.fetch$ = () => + of({ rawResponse: { hits: { hits } } } as unknown as IKibanaSearchResponse); + expect(fetchDocuments(savedSearchMock.searchSource, getDeps())).resolves.toEqual(hits); }); - test('change of fetchStatus on fetch error', async (done) => { - const subjects = getDataSubjects(); - const { documents$ } = subjects; - const deps = { - abortController: new AbortController(), - inspectorAdapters: { requests: new RequestAdapter() }, - onResults: jest.fn(), - searchSessionId: '123', - services: discoverServiceMock, - }; + test('rejects on query failure', () => { savedSearchMock.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' }); - const stateArr: FetchStatus[] = []; - - documents$.subscribe((value) => stateArr.push(value.fetchStatus)); - - fetchDocuments(subjects, savedSearchMock.searchSource, deps).subscribe({ - error: () => { - expect(stateArr).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.ERROR, - ]); - done(); - }, + expect(fetchDocuments(savedSearchMock.searchSource, getDeps())).rejects.toEqual({ + msg: 'Oh noes!', }); }); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_documents.ts b/src/plugins/discover/public/application/main/utils/fetch_documents.ts index b23dd3a0ed932..0c83b85b2bc62 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_documents.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_documents.ts @@ -6,34 +6,30 @@ * Side Public License, v 1. */ import { i18n } from '@kbn/i18n'; -import { filter } from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; import { Adapters } from '../../../../../inspector/common'; import { isCompleteResponse, ISearchSource } from '../../../../../data/common'; -import { FetchStatus } from '../../types'; -import { SavedSearchData } from './use_saved_search'; -import { sendErrorMsg, sendLoadingMsg } from './use_saved_search_messages'; import { SAMPLE_SIZE_SETTING } from '../../../../common'; import { DiscoverServices } from '../../../build_services'; +/** + * Requests the documents for Discover. This will return a promise that will resolve + * with the documents. + */ export const fetchDocuments = ( - data$: SavedSearchData, searchSource: ISearchSource, { abortController, inspectorAdapters, - onResults, searchSessionId, services, }: { abortController: AbortController; inspectorAdapters: Adapters; - onResults: (foundDocuments: boolean) => void; searchSessionId: string; services: DiscoverServices; } ) => { - const { documents$, totalHits$ } = data$; - searchSource.setField('size', services.uiSettings.get(SAMPLE_SIZE_SETTING)); searchSource.setField('trackTotalHits', false); searchSource.setField('highlightAll', true); @@ -46,8 +42,6 @@ export const fetchDocuments = ( searchSource.setOverwriteDataViewType(undefined); } - sendLoadingMsg(documents$); - const executionContext = { type: 'application', name: 'discover', @@ -71,34 +65,10 @@ export const fetchDocuments = ( }, executionContext, }) - .pipe(filter((res) => isCompleteResponse(res))); - - fetch$.subscribe( - (res) => { - const documents = res.rawResponse.hits.hits; - - // If the total hits query is still loading for hits, emit a partial - // hit count that's at least our document count - if (totalHits$.getValue().fetchStatus === FetchStatus.LOADING) { - totalHits$.next({ - fetchStatus: FetchStatus.PARTIAL, - result: documents.length, - }); - } - - documents$.next({ - fetchStatus: FetchStatus.COMPLETE, - result: documents, - }); - onResults(documents.length > 0); - }, - (error) => { - if (error instanceof Error && error.name === 'AbortError') { - return; - } + .pipe( + filter((res) => isCompleteResponse(res)), + map((res) => res.rawResponse.hits.hits) + ); - sendErrorMsg(documents$, error); - } - ); - return fetch$; + return fetch$.toPromise(); }; diff --git a/src/plugins/discover/public/application/main/utils/fetch_total_hits.test.ts b/src/plugins/discover/public/application/main/utils/fetch_total_hits.test.ts index c593c9c157422..7b564906f95a7 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_total_hits.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_total_hits.test.ts @@ -5,76 +5,34 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { FetchStatus } from '../../types'; -import { BehaviorSubject, throwError as throwErrorRx } from 'rxjs'; +import { throwError as throwErrorRx, of } from 'rxjs'; import { RequestAdapter } from '../../../../../inspector'; import { savedSearchMock } from '../../../__mocks__/saved_search'; import { fetchTotalHits } from './fetch_total_hits'; import { discoverServiceMock } from '../../../__mocks__/services'; - -function getDataSubjects() { - return { - main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), - }; -} +import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; + +const getDeps = () => ({ + abortController: new AbortController(), + inspectorAdapters: { requests: new RequestAdapter() }, + searchSessionId: '123', + data: discoverServiceMock.data, +}); describe('test fetchTotalHits', () => { - test('changes of fetchStatus are correct when starting with FetchStatus.UNINITIALIZED', async (done) => { - const subjects = getDataSubjects(); - const { totalHits$ } = subjects; - - const deps = { - abortController: new AbortController(), - inspectorAdapters: { requests: new RequestAdapter() }, - onResults: jest.fn(), - searchSessionId: '123', - data: discoverServiceMock.data, - }; - - const stateArr: FetchStatus[] = []; + test('resolves returned promise with hit count', async () => { + savedSearchMock.searchSource.fetch$ = () => + of({ rawResponse: { hits: { total: 45 } } } as IKibanaSearchResponse); - totalHits$.subscribe((value) => stateArr.push(value.fetchStatus)); - - fetchTotalHits(subjects, savedSearchMock.searchSource, deps).subscribe({ - complete: () => { - expect(stateArr).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.COMPLETE, - ]); - done(); - }, - }); + await expect(fetchTotalHits(savedSearchMock.searchSource, getDeps())).resolves.toBe(45); }); - test('change of fetchStatus on fetch error', async (done) => { - const subjects = getDataSubjects(); - const { totalHits$ } = subjects; - const deps = { - abortController: new AbortController(), - inspectorAdapters: { requests: new RequestAdapter() }, - onResults: jest.fn(), - searchSessionId: '123', - data: discoverServiceMock.data, - }; + test('rejects in case of an error', async () => { savedSearchMock.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' }); - const stateArr: FetchStatus[] = []; - - totalHits$.subscribe((value) => stateArr.push(value.fetchStatus)); - - fetchTotalHits(subjects, savedSearchMock.searchSource, deps).subscribe({ - error: () => { - expect(stateArr).toEqual([ - FetchStatus.UNINITIALIZED, - FetchStatus.LOADING, - FetchStatus.ERROR, - ]); - done(); - }, + await expect(fetchTotalHits(savedSearchMock.searchSource, getDeps())).rejects.toEqual({ + msg: 'Oh noes!', }); }); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_total_hits.ts b/src/plugins/discover/public/application/main/utils/fetch_total_hits.ts index 197e00ce0449f..55fc9c1c17235 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_total_hits.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_total_hits.ts @@ -7,36 +7,23 @@ */ import { i18n } from '@kbn/i18n'; -import { filter } from 'rxjs/operators'; -import { - DataPublicPluginStart, - isCompleteResponse, - ISearchSource, -} from '../../../../../data/public'; +import { filter, map } from 'rxjs/operators'; +import { isCompleteResponse, ISearchSource } from '../../../../../data/public'; import { DataViewType } from '../../../../../data_views/common'; import { Adapters } from '../../../../../inspector/common'; -import { FetchStatus } from '../../types'; -import { SavedSearchData } from './use_saved_search'; -import { sendErrorMsg, sendLoadingMsg } from './use_saved_search_messages'; export function fetchTotalHits( - data$: SavedSearchData, searchSource: ISearchSource, { abortController, - data, inspectorAdapters, - onResults, searchSessionId, }: { abortController: AbortController; - data: DataPublicPluginStart; - onResults: (foundDocuments: boolean) => void; inspectorAdapters: Adapters; searchSessionId: string; } ) { - const { totalHits$ } = data$; searchSource.setField('trackTotalHits', true); searchSource.setField('size', 0); searchSource.removeField('sort'); @@ -50,8 +37,6 @@ export function fetchTotalHits( searchSource.setOverwriteDataViewType(undefined); } - sendLoadingMsg(totalHits$); - const executionContext = { type: 'application', name: 'discover', @@ -75,21 +60,10 @@ export function fetchTotalHits( sessionId: searchSessionId, executionContext, }) - .pipe(filter((res) => isCompleteResponse(res))); - - fetch$.subscribe( - (res) => { - const totalHitsNr = res.rawResponse.hits.total as number; - totalHits$.next({ fetchStatus: FetchStatus.COMPLETE, result: totalHitsNr }); - onResults(totalHitsNr > 0); - }, - (error) => { - if (error instanceof Error && error.name === 'AbortError') { - return; - } - sendErrorMsg(totalHits$, error); - } - ); + .pipe( + filter((res) => isCompleteResponse(res)), + map((res) => res.rawResponse.hits.total as number) + ); - return fetch$; + return fetch$.toPromise(); } diff --git a/src/plugins/discover/public/application/main/utils/use_saved_search.ts b/src/plugins/discover/public/application/main/utils/use_saved_search.ts index 0f4b9058316a0..f37fdef4bd655 100644 --- a/src/plugins/discover/public/application/main/utils/use_saved_search.ts +++ b/src/plugins/discover/public/application/main/utils/use_saved_search.ts @@ -159,7 +159,7 @@ export const useSavedSearch = ({ initialFetchStatus, }); - const subscription = fetch$.subscribe((val) => { + const subscription = fetch$.subscribe(async (val) => { if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) { return; } @@ -167,28 +167,26 @@ export const useSavedSearch = ({ refs.current.abortController?.abort(); refs.current.abortController = new AbortController(); - try { - fetchAll(dataSubjects, searchSource, val === 'reset', { - abortController: refs.current.abortController, - appStateContainer: stateContainer.appStateContainer, - inspectorAdapters, - data, - initialFetchStatus, - searchSessionId: searchSessionManager.getNextSearchSessionId(), - services, - useNewFieldsApi, - }).subscribe({ - complete: () => { - // if this function was set and is executed, another refresh fetch can be triggered - refs.current.autoRefreshDone?.(); - refs.current.autoRefreshDone = undefined; - }, - }); - } catch (error) { - main$.next({ - fetchStatus: FetchStatus.ERROR, - error, - }); + const autoRefreshDone = refs.current.autoRefreshDone; + + await fetchAll(dataSubjects, searchSource, val === 'reset', { + abortController: refs.current.abortController, + appStateContainer: stateContainer.appStateContainer, + inspectorAdapters, + data, + initialFetchStatus, + searchSessionId: searchSessionManager.getNextSearchSessionId(), + services, + useNewFieldsApi, + }); + + // If the autoRefreshCallback is still the same as when we started i.e. there was no newer call + // replacing this current one, call it to make sure we tell that the auto refresh is done + // and a new one can be scheduled. + if (autoRefreshDone === refs.current.autoRefreshDone) { + // if this function was set and is executed, another refresh fetch can be triggered + refs.current.autoRefreshDone?.(); + refs.current.autoRefreshDone = undefined; } }); diff --git a/src/plugins/discover/public/application/main/utils/use_saved_search_messages.test.ts b/src/plugins/discover/public/application/main/utils/use_saved_search_messages.test.ts index 2fa264690329e..0d74061ac46a3 100644 --- a/src/plugins/discover/public/application/main/utils/use_saved_search_messages.test.ts +++ b/src/plugins/discover/public/application/main/utils/use_saved_search_messages.test.ts @@ -9,14 +9,16 @@ import { sendCompleteMsg, sendErrorMsg, sendLoadingMsg, + sendNoResultsFoundMsg, sendPartialMsg, } from './use_saved_search_messages'; import { FetchStatus } from '../../types'; import { BehaviorSubject } from 'rxjs'; import { DataMainMsg } from './use_saved_search'; +import { filter } from 'rxjs/operators'; describe('test useSavedSearch message generators', () => { - test('sendCompleteMsg', async (done) => { + test('sendCompleteMsg', (done) => { const main$ = new BehaviorSubject({ fetchStatus: FetchStatus.LOADING }); main$.subscribe((value) => { if (value.fetchStatus !== FetchStatus.LOADING) { @@ -28,7 +30,18 @@ describe('test useSavedSearch message generators', () => { }); sendCompleteMsg(main$, true); }); - test('sendPartialMessage', async (done) => { + test('sendNoResultsFoundMsg', (done) => { + const main$ = new BehaviorSubject({ fetchStatus: FetchStatus.LOADING }); + main$ + .pipe(filter(({ fetchStatus }) => fetchStatus !== FetchStatus.LOADING)) + .subscribe((value) => { + expect(value.fetchStatus).toBe(FetchStatus.COMPLETE); + expect(value.foundDocuments).toBe(false); + done(); + }); + sendNoResultsFoundMsg(main$); + }); + test('sendPartialMessage', (done) => { const main$ = new BehaviorSubject({ fetchStatus: FetchStatus.LOADING }); main$.subscribe((value) => { if (value.fetchStatus !== FetchStatus.LOADING) { @@ -38,7 +51,7 @@ describe('test useSavedSearch message generators', () => { }); sendPartialMsg(main$); }); - test('sendLoadingMsg', async (done) => { + test('sendLoadingMsg', (done) => { const main$ = new BehaviorSubject({ fetchStatus: FetchStatus.COMPLETE }); main$.subscribe((value) => { if (value.fetchStatus !== FetchStatus.COMPLETE) { @@ -48,7 +61,7 @@ describe('test useSavedSearch message generators', () => { }); sendLoadingMsg(main$); }); - test('sendErrorMsg', async (done) => { + test('sendErrorMsg', (done) => { const main$ = new BehaviorSubject({ fetchStatus: FetchStatus.PARTIAL }); main$.subscribe((value) => { if (value.fetchStatus === FetchStatus.ERROR) { @@ -60,7 +73,7 @@ describe('test useSavedSearch message generators', () => { sendErrorMsg(main$, new Error('Pls help!')); }); - test('sendCompleteMsg cleaning error state message', async (done) => { + test('sendCompleteMsg cleaning error state message', (done) => { const initialState = { fetchStatus: FetchStatus.ERROR, error: new Error('Oh noes!'), diff --git a/src/plugins/discover/public/application/main/utils/use_saved_search_messages.ts b/src/plugins/discover/public/application/main/utils/use_saved_search_messages.ts index 325d63eb6d21a..a2d42147a9e8f 100644 --- a/src/plugins/discover/public/application/main/utils/use_saved_search_messages.ts +++ b/src/plugins/discover/public/application/main/utils/use_saved_search_messages.ts @@ -15,6 +15,15 @@ import { SavedSearchData, } from './use_saved_search'; +/** + * Sends COMPLETE message to the main$ observable with the information + * that no documents have been found, allowing Discover to show a no + * results message. + */ +export function sendNoResultsFoundMsg(main$: DataMain$) { + sendCompleteMsg(main$, false); +} + /** * Send COMPLETE message via main observable used when * 1.) first fetch resolved, and there are no documents diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 9cc2eb78aafbe..9f21294efdfc1 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -66,7 +66,7 @@ export interface DiscoverServices { toastNotifications: ToastsStart; uiSettings: IUiSettingsClient; trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; - indexPatternFieldEditor: IndexPatternFieldEditorStart; + dataViewFieldEditor: IndexPatternFieldEditorStart; http: HttpStart; storage: Storage; spaces?: SpacesApi; @@ -105,7 +105,7 @@ export function buildServices( uiSettings: core.uiSettings, storage, trackUiMetric: usageCollection?.reportUiCounter.bind(usageCollection, 'discover'), - indexPatternFieldEditor: plugins.dataViewFieldEditor, + dataViewFieldEditor: plugins.dataViewFieldEditor, http: core.http, spaces: plugins.spaces, }; diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_flyout.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_flyout.tsx index 30e0cf24f7d52..27f4268224904 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_flyout.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_flyout.tsx @@ -27,8 +27,7 @@ import { import { DocViewer } from '../../services/doc_views/components/doc_viewer/doc_viewer'; import { DocViewFilterFn } from '../../services/doc_views/doc_views_types'; import { DiscoverServices } from '../../build_services'; -import { getContextUrl } from '../../utils/get_context_url'; -import { getSingleDocUrl } from '../../utils/get_single_doc_url'; +import { useNavigationProps } from '../../utils/use_navigation_props'; import { ElasticSearchHit } from '../../types'; interface Props { @@ -103,6 +102,15 @@ export function DiscoverGridFlyout({ [activePage, setPage] ); + const { singleDocProps, surrDocsProps } = useNavigationProps({ + indexPatternId: indexPattern.id!, + rowIndex: hit._index, + rowId: hit._id, + filterManager: services.filterManager, + addBasePath: services.addBasePath, + columns, + }); + return ( {i18n.translate('discover.grid.tableRow.viewSingleDocumentLinkTextSimple', { defaultMessage: 'Single document', @@ -157,13 +165,7 @@ export function DiscoverGridFlyout({ size="xs" iconType="documents" flush="left" - href={getContextUrl( - String(hit._id), - indexPattern.id, - columns, - services.filterManager, - services.addBasePath - )} + {...surrDocsProps} data-test-subj="docTableRowAction" > {i18n.translate('discover.grid.tableRow.viewSurroundingDocumentsLinkTextSimple', { diff --git a/src/plugins/discover/public/components/doc_table/components/table_row.tsx b/src/plugins/discover/public/components/doc_table/components/table_row.tsx index 2eee9a177e4f8..2d9e8fa6e9584 100644 --- a/src/plugins/discover/public/components/doc_table/components/table_row.tsx +++ b/src/plugins/discover/public/components/doc_table/components/table_row.tsx @@ -15,12 +15,11 @@ import { flattenHit } from '../../../../../data/common'; import { DocViewer } from '../../../services/doc_views/components/doc_viewer/doc_viewer'; import { FilterManager, IndexPattern } from '../../../../../data/public'; import { TableCell } from './table_row/table_cell'; -import { DocViewFilterFn } from '../../../services/doc_views/doc_views_types'; -import { getContextUrl } from '../../../utils/get_context_url'; -import { getSingleDocUrl } from '../../../utils/get_single_doc_url'; -import { TableRowDetails } from './table_row_details'; import { formatRow, formatTopLevelObject } from '../lib/row_formatter'; +import { useNavigationProps } from '../../../utils/use_navigation_props'; +import { DocViewFilterFn } from '../../../services/doc_views/doc_views_types'; import { ElasticSearchHit } from '../../../types'; +import { TableRowDetails } from './table_row_details'; export type DocTableRow = ElasticSearchHit & { isAnchor?: boolean; @@ -100,13 +99,14 @@ export const TableRow = ({ [filter, flattenedRow, indexPattern.fields] ); - const getContextAppHref = () => { - return getContextUrl(row._id, indexPattern.id!, columns, filterManager, addBasePath); - }; - - const getSingleDocHref = () => { - return addBasePath(getSingleDocUrl(indexPattern.id!, row._index, row._id)); - }; + const { singleDocProps, surrDocsProps } = useNavigationProps({ + indexPatternId: indexPattern.id!, + rowIndex: row._index, + rowId: row._id, + filterManager, + addBasePath, + columns, + }); const rowCells = [ @@ -208,8 +208,8 @@ export const TableRow = ({ open={open} colLength={(columns.length || 1) + 2} isTimeBased={indexPattern.isTimeBased()} - getContextAppHref={getContextAppHref} - getSingleDocHref={getSingleDocHref} + singleDocProps={singleDocProps} + surrDocsProps={surrDocsProps} > string; - getSingleDocHref: () => string; + singleDocProps: DiscoverNavigationProps; + surrDocsProps: DiscoverNavigationProps; children: JSX.Element; } @@ -22,8 +23,8 @@ export const TableRowDetails = ({ open, colLength, isTimeBased, - getContextAppHref, - getSingleDocHref, + singleDocProps, + surrDocsProps, children, }: TableRowDetailsProps) => { if (!open) { @@ -54,7 +55,7 @@ export const TableRowDetails = ({ {isTimeBased && ( - + - + } > -
- - - -
- - -

- An Error Occurred -

-
- - - + + + +
+
- - -
-
- Could not fetch data at this time. Refresh the tab to try again. - +

-
- - + + + - - - - -
+ + + +

+
+
+ +
- - - -
+
+
+
+ `; diff --git a/src/plugins/discover/public/utils/breadcrumbs.ts b/src/plugins/discover/public/utils/breadcrumbs.ts index 4a3df34e2da75..4d79598ce5389 100644 --- a/src/plugins/discover/public/utils/breadcrumbs.ts +++ b/src/plugins/discover/public/utils/breadcrumbs.ts @@ -10,13 +10,13 @@ import { ChromeStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { SavedSearch } from '../services/saved_searches'; -export function getRootBreadcrumbs() { +export function getRootBreadcrumbs(breadcrumb?: string) { return [ { text: i18n.translate('discover.rootBreadcrumb', { defaultMessage: 'Discover', }), - href: '#/', + href: breadcrumb || '#/', }, ]; } diff --git a/src/plugins/discover/public/utils/get_context_url.test.ts b/src/plugins/discover/public/utils/get_context_url.test.ts deleted file mode 100644 index d6d1db5ca393b..0000000000000 --- a/src/plugins/discover/public/utils/get_context_url.test.ts +++ /dev/null @@ -1,43 +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 { getContextUrl } from './get_context_url'; -import { FilterManager } from '../../../data/public/query/filter_manager'; -const filterManager = { - getGlobalFilters: () => [], - getAppFilters: () => [], -} as unknown as FilterManager; -const addBasePath = (path: string) => `/base${path}`; - -describe('Get context url', () => { - test('returning a valid context url', async () => { - const url = await getContextUrl( - 'docId', - 'ipId', - ['test1', 'test2'], - filterManager, - addBasePath - ); - expect(url).toMatchInlineSnapshot( - `"/base/app/discover#/context/ipId/docId?_g=(filters:!())&_a=(columns:!(test1,test2),filters:!())"` - ); - }); - - test('returning a valid context url when docId contains whitespace', async () => { - const url = await getContextUrl( - 'doc Id', - 'ipId', - ['test1', 'test2'], - filterManager, - addBasePath - ); - expect(url).toMatchInlineSnapshot( - `"/base/app/discover#/context/ipId/doc%20Id?_g=(filters:!())&_a=(columns:!(test1,test2),filters:!())"` - ); - }); -}); diff --git a/src/plugins/discover/public/utils/get_context_url.tsx b/src/plugins/discover/public/utils/get_context_url.tsx deleted file mode 100644 index 68c0e935f17e9..0000000000000 --- a/src/plugins/discover/public/utils/get_context_url.tsx +++ /dev/null @@ -1,46 +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 { stringify } from 'query-string'; -import rison from 'rison-node'; -import { url } from '../../../kibana_utils/common'; -import { esFilters, FilterManager } from '../../../data/public'; -import { DiscoverServices } from '../build_services'; - -/** - * Helper function to generate an URL to a document in Discover's context view - */ -export function getContextUrl( - documentId: string, - indexPatternId: string, - columns: string[], - filterManager: FilterManager, - addBasePath: DiscoverServices['addBasePath'] -) { - const globalFilters = filterManager.getGlobalFilters(); - const appFilters = filterManager.getAppFilters(); - - const hash = stringify( - url.encodeQuery({ - _g: rison.encode({ - filters: globalFilters || [], - }), - _a: rison.encode({ - columns, - filters: (appFilters || []).map(esFilters.disableFilter), - }), - }), - { encode: false, sort: false } - ); - - return addBasePath( - `/app/discover#/context/${encodeURIComponent(indexPatternId)}/${encodeURIComponent( - documentId - )}?${hash}` - ); -} diff --git a/src/plugins/discover/public/utils/use_navigation_props.test.tsx b/src/plugins/discover/public/utils/use_navigation_props.test.tsx new file mode 100644 index 0000000000000..29d4976f265c3 --- /dev/null +++ b/src/plugins/discover/public/utils/use_navigation_props.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 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, { ReactElement } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { createFilterManagerMock } from '../../../data/public/query/filter_manager/filter_manager.mock'; +import { + getContextHash, + HistoryState, + useNavigationProps, + UseNavigationProps, +} from './use_navigation_props'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { setServices } from '../kibana_services'; +import { DiscoverServices } from '../build_services'; + +const filterManager = createFilterManagerMock(); +const defaultProps = { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + rowIndex: 'kibana_sample_data_ecommerce', + rowId: 'QmsYdX0BQ6gV8MTfoPYE', + columns: ['customer_first_name', 'products.manufacturer'], + filterManager, + addBasePath: jest.fn(), +} as UseNavigationProps; +const basePathPrefix = 'localhost:5601/xqj'; + +const getSearch = () => { + return `?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) + &_a=(columns:!(${defaultProps.columns.join()}),filters:!(),index:${defaultProps.indexPatternId} + ,interval:auto,query:(language:kuery,query:''),sort:!(!(order_date,desc)))`; +}; + +const getSingeDocRoute = () => { + return `/doc/${defaultProps.indexPatternId}/${defaultProps.rowIndex}`; +}; + +const getContextRoute = () => { + return `/context/${defaultProps.indexPatternId}/${defaultProps.rowId}`; +}; + +const render = () => { + const history = createMemoryHistory({ + initialEntries: ['/' + getSearch()], + }); + setServices({ history: () => history } as unknown as DiscoverServices); + const wrapper = ({ children }: { children: ReactElement }) => ( + {children} + ); + return { + result: renderHook(() => useNavigationProps(defaultProps), { wrapper }).result, + history, + }; +}; + +describe('useNavigationProps', () => { + test('should provide valid breadcrumb for single doc page from main view', () => { + const { result, history } = render(); + + result.current.singleDocProps.onClick?.(); + expect(history.location.pathname).toEqual(getSingeDocRoute()); + expect(history.location.search).toEqual(`?id=${defaultProps.rowId}`); + expect(history.location.state?.breadcrumb).toEqual(`#/${getSearch()}`); + }); + + test('should provide valid breadcrumb for context page from main view', () => { + const { result, history } = render(); + + result.current.surrDocsProps.onClick?.(); + expect(history.location.pathname).toEqual(getContextRoute()); + expect(history.location.search).toEqual( + `?${getContextHash(defaultProps.columns, filterManager)}` + ); + expect(history.location.state?.breadcrumb).toEqual(`#/${getSearch()}`); + }); + + test('should create valid links to the context and single doc pages from embeddable', () => { + const { result } = renderHook(() => + useNavigationProps({ + ...defaultProps, + addBasePath: (val: string) => `${basePathPrefix}${val}`, + }) + ); + + expect(result.current.singleDocProps.href!).toEqual( + `${basePathPrefix}/app/discover#${getSingeDocRoute()}?id=${defaultProps.rowId}` + ); + expect(result.current.surrDocsProps.href!).toEqual( + `${basePathPrefix}/app/discover#${getContextRoute()}?${getContextHash( + defaultProps.columns, + filterManager + )}` + ); + }); +}); diff --git a/src/plugins/discover/public/utils/use_navigation_props.tsx b/src/plugins/discover/public/utils/use_navigation_props.tsx new file mode 100644 index 0000000000000..6f1dedf75e730 --- /dev/null +++ b/src/plugins/discover/public/utils/use_navigation_props.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 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 { useMemo, useRef } from 'react'; +import { useHistory, matchPath } from 'react-router-dom'; +import { stringify } from 'query-string'; +import rison from 'rison-node'; +import { esFilters, FilterManager } from '../../../data/public'; +import { url } from '../../../kibana_utils/common'; +import { getServices } from '../kibana_services'; + +export type DiscoverNavigationProps = { onClick: () => void } | { href: string }; + +export interface UseNavigationProps { + indexPatternId: string; + rowIndex: string; + rowId: string; + columns: string[]; + filterManager: FilterManager; + addBasePath: (url: string) => string; +} + +export type HistoryState = { breadcrumb?: string } | undefined; + +export const getContextHash = (columns: string[], filterManager: FilterManager) => { + const globalFilters = filterManager.getGlobalFilters(); + const appFilters = filterManager.getAppFilters(); + + const hash = stringify( + url.encodeQuery({ + _g: rison.encode({ + filters: globalFilters || [], + }), + _a: rison.encode({ + columns, + filters: (appFilters || []).map(esFilters.disableFilter), + }), + }), + { encode: false, sort: false } + ); + + return hash; +}; + +/** + * When it's context route, breadcrumb link should point to the main discover page anyway. + * Otherwise, we are on main page and should create breadcrumb link from it. + * Current history object should be used in callback, since url state might be changed + * after expanded document opened. + */ +const getCurrentBreadcrumbs = (isContextRoute: boolean, prevBreadcrumb?: string) => { + const { history: getHistory } = getServices(); + const currentHistory = getHistory(); + return isContextRoute + ? prevBreadcrumb + : '#' + currentHistory?.location.pathname + currentHistory?.location.search; +}; + +export const useMainRouteBreadcrumb = () => { + // useRef needed to retrieve initial breadcrumb link from the push state without updates + return useRef(useHistory().location.state?.breadcrumb).current; +}; + +export const useNavigationProps = ({ + indexPatternId, + rowIndex, + rowId, + columns, + filterManager, + addBasePath, +}: UseNavigationProps) => { + const history = useHistory(); + const prevBreadcrumb = useRef(history?.location.state?.breadcrumb).current; + const contextSearchHash = useMemo( + () => getContextHash(columns, filterManager), + [columns, filterManager] + ); + + /** + * When history can be accessed via hooks, + * it is discover main or context route. + */ + if (!!history) { + const isContextRoute = matchPath(history.location.pathname, { + path: '/context/:indexPatternId/:id', + exact: true, + }); + + const onOpenSingleDoc = () => { + history.push({ + pathname: `/doc/${indexPatternId}/${rowIndex}`, + search: `?id=${encodeURIComponent(rowId)}`, + state: { breadcrumb: getCurrentBreadcrumbs(!!isContextRoute, prevBreadcrumb) }, + }); + }; + + const onOpenSurrDocs = () => + history.push({ + pathname: `/context/${encodeURIComponent(indexPatternId)}/${encodeURIComponent( + String(rowId) + )}`, + search: `?${contextSearchHash}`, + state: { breadcrumb: getCurrentBreadcrumbs(!!isContextRoute, prevBreadcrumb) }, + }); + + return { + singleDocProps: { onClick: onOpenSingleDoc }, + surrDocsProps: { onClick: onOpenSurrDocs }, + }; + } + + // for embeddable absolute href should be kept + return { + singleDocProps: { + href: addBasePath( + `/app/discover#/doc/${indexPatternId}/${rowIndex}?id=${encodeURIComponent(rowId)}` + ), + }, + surrDocsProps: { + href: addBasePath( + `/app/discover#/context/${encodeURIComponent(indexPatternId)}/${encodeURIComponent( + rowId + )}?${contextSearchHash}` + ), + }, + }; +}; diff --git a/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx index 9a575a9446a01..e7a9127f17040 100644 --- a/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx +++ b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx @@ -164,7 +164,10 @@ export class AttributeService< try { const newAttributes = { ...(input as ValType)[ATTRIBUTE_SERVICE_KEY] }; newAttributes.title = props.newTitle; - const wrappedInput = (await this.wrapAttributes(newAttributes, true)) as RefType; + const wrappedInput = (await this.wrapAttributes( + newAttributes, + true + )) as unknown as RefType; // Remove unneeded attributes from the original input. const newInput = omit(input, ATTRIBUTE_SERVICE_KEY); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx index a392502251039..624d5cc56288a 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx @@ -130,6 +130,7 @@ export class AddPanelFlyout extends React.Component { private getCreateMenuItems(): ReactElement[] { return [...this.props.getAllFactories()] .filter( + // @ts-expect-error ts 4.5 upgrade (factory) => factory.isEditable() && !factory.isContainerType && factory.canCreateNew() ) .map((factory) => ( diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 9b889c62e9ff5..6dab9f7c683ed 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -310,6 +310,13 @@ describe('Execution', () => { const { result } = await run('var name="foo"', { variables }); expect(result).toBe('bar'); }); + + test('can access variables set from the parent expression', async () => { + const { result } = await run( + 'var_set name="a" value="bar" | var_set name="b" value={var name="a"} | var name="b"' + ); + expect(result).toBe('bar'); + }); }); describe('inspector adapters', () => { diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index c81e398dbc9e8..f355710698b69 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -542,10 +542,10 @@ export class Execution< interpret(ast: ExpressionAstNode, input: T): Observable> { switch (getType(ast)) { case 'expression': - const execution = this.execution.executor.createExecution( - ast as ExpressionAstExpression, - this.execution.params - ); + const execution = this.execution.executor.createExecution(ast as ExpressionAstExpression, { + ...this.execution.params, + variables: this.context.variables, + }); this.childExecutions.push(execution); return execution.start(input, true); diff --git a/src/plugins/home/server/tutorials/instructions/cloud_instructions.ts b/src/plugins/home/server/tutorials/instructions/cloud_instructions.ts index ed9e588a999b4..6d547b2a1d40d 100644 --- a/src/plugins/home/server/tutorials/instructions/cloud_instructions.ts +++ b/src/plugins/home/server/tutorials/instructions/cloud_instructions.ts @@ -7,13 +7,14 @@ */ import { i18n } from '@kbn/i18n'; + export const cloudPasswordAndResetLink = i18n.translate( 'home.tutorials.common.cloudInstructions.passwordAndResetLink', { defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.' + `\\{#config.cloud.profileUrl\\} - Forgot the password? [Reset in Elastic Cloud](\\{config.cloud.baseUrl\\}\\{config.cloud.profileUrl\\}). + Forgot the password? [Reset in Elastic Cloud](\\{config.cloud.baseUrl\\}\\{config.cloud.deploymentUrl\\}/security). \\{/config.cloud.profileUrl\\}`, values: { passwordTemplate: '``' }, } diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.test.ts b/src/plugins/interactive_setup/server/kibana_config_writer.test.ts index 82d02882698d4..d097d7cc4a05d 100644 --- a/src/plugins/interactive_setup/server/kibana_config_writer.test.ts +++ b/src/plugins/interactive_setup/server/kibana_config_writer.test.ts @@ -35,7 +35,8 @@ describe('KibanaConfigWriter', () => { throw new Error('Invalid certificate'); } return { - fingerprint256: 'fingerprint256', + fingerprint256: + 'D4:86:CE:00:AC:71:E4:1D:2B:70:D0:87:A5:55:FA:5D:D1:93:6C:DB:45:80:79:53:7B:A3:AC:13:3E:48:34:D6', }; }; @@ -131,7 +132,7 @@ describe('KibanaConfigWriter', () => { elasticsearch.hosts: [some-host] elasticsearch.serviceAccountToken: some-value elasticsearch.ssl.certificateAuthorities: [/data/ca_1234.crt] - xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, is_default_monitoring: true, type: elasticsearch, hosts: [some-host], ca_sha256: fingerprint256}] + xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, is_default_monitoring: true, type: elasticsearch, hosts: [some-host], ca_trusted_fingerprint: d486ce00ac71e41d2b70d087a555fa5dd1936cdb458079537ba3ac133e4834d6}] ", ], @@ -198,7 +199,7 @@ describe('KibanaConfigWriter', () => { elasticsearch.username: username elasticsearch.password: password elasticsearch.ssl.certificateAuthorities: [/data/ca_1234.crt] - xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, is_default_monitoring: true, type: elasticsearch, hosts: [some-host], ca_sha256: fingerprint256}] + xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, is_default_monitoring: true, type: elasticsearch, hosts: [some-host], ca_trusted_fingerprint: d486ce00ac71e41d2b70d087a555fa5dd1936cdb458079537ba3ac133e4834d6}] ", ], @@ -275,7 +276,7 @@ describe('KibanaConfigWriter', () => { elasticsearch.hosts: [some-host] elasticsearch.serviceAccountToken: some-value elasticsearch.ssl.certificateAuthorities: [/data/ca_1234.crt] - xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, is_default_monitoring: true, type: elasticsearch, hosts: [some-host], ca_sha256: fingerprint256}] + xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, is_default_monitoring: true, type: elasticsearch, hosts: [some-host], ca_trusted_fingerprint: d486ce00ac71e41d2b70d087a555fa5dd1936cdb458079537ba3ac133e4834d6}] ", ], @@ -329,7 +330,7 @@ describe('KibanaConfigWriter', () => { monitoring.ui.container.elasticsearch.enabled: true elasticsearch.serviceAccountToken: some-value elasticsearch.ssl.certificateAuthorities: [/data/ca_1234.crt] - xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, is_default_monitoring: true, type: elasticsearch, hosts: [some-host], ca_sha256: fingerprint256}] + xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, is_default_monitoring: true, type: elasticsearch, hosts: [some-host], ca_trusted_fingerprint: d486ce00ac71e41d2b70d087a555fa5dd1936cdb458079537ba3ac133e4834d6}] ", ], diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.ts b/src/plugins/interactive_setup/server/kibana_config_writer.ts index af177fee33bce..eac1bd0cef175 100644 --- a/src/plugins/interactive_setup/server/kibana_config_writer.ts +++ b/src/plugins/interactive_setup/server/kibana_config_writer.ts @@ -38,7 +38,7 @@ interface FleetOutputConfig { is_default_monitoring: boolean; type: 'elasticsearch'; hosts: string[]; - ca_sha256: string; + ca_trusted_fingerprint: string; } export class KibanaConfigWriter { @@ -187,7 +187,8 @@ export class KibanaConfigWriter { */ private static getFleetDefaultOutputConfig(caCert: string, host: string): FleetOutputConfig[] { const cert = new X509Certificate(caCert); - const certFingerprint = cert.fingerprint256; + // fingerprint256 is a ":" separated uppercase hexadecimal string + const certFingerprint = cert.fingerprint256.split(':').join('').toLowerCase(); return [ { @@ -197,7 +198,7 @@ export class KibanaConfigWriter { is_default_monitoring: true, type: 'elasticsearch', hosts: [host], - ca_sha256: certFingerprint, + ca_trusted_fingerprint: certFingerprint, }, ]; } diff --git a/src/plugins/management/public/components/management_app/management_app.tsx b/src/plugins/management/public/components/management_app/management_app.tsx index 8a520a2629c3b..4d306ffd2e266 100644 --- a/src/plugins/management/public/components/management_app/management_app.tsx +++ b/src/plugins/management/public/components/management_app/management_app.tsx @@ -8,17 +8,18 @@ import './management_app.scss'; import React, { useState, useEffect, useCallback } from 'react'; -import { AppMountParameters, ChromeBreadcrumb, ScopedHistory } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { ManagementSection, MANAGEMENT_BREADCRUMB } from '../../utils'; +import { AppMountParameters, ChromeBreadcrumb, ScopedHistory } from 'kibana/public'; +import { ManagementSection, MANAGEMENT_BREADCRUMB } from '../../utils'; import { ManagementRouter } from './management_router'; import { managementSidebarNav } from '../management_sidebar_nav/management_sidebar_nav'; import { KibanaPageTemplate, KibanaPageTemplateProps, reactRouterNavigate, + KibanaThemeProvider, } from '../../../../kibana_react/public'; import { SectionsServiceStart } from '../../types'; @@ -83,24 +84,26 @@ export const ManagementApp = ({ dependencies, history, theme$ }: ManagementAppPr return ( - - - + + + + + ); }; diff --git a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts index 3f1966901544a..83f33a894b903 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts @@ -41,8 +41,6 @@ export function handleKibanaStats( const { kibana, kibana_stats: kibanaStats, ...plugins } = response; const os = { - platform: 'unknown', - platformRelease: 'unknown', ...kibanaStats.os, }; const formattedOsStats = Object.entries(os).reduce((acc, [key, value]) => { diff --git a/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx b/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx index 5d466c2f4b3c8..e20c7eb0f8c21 100644 --- a/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx +++ b/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx @@ -98,7 +98,14 @@ export const getHeatmapVisTypeDefinition = ({ }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -106,7 +113,14 @@ export const getHeatmapVisTypeDefinition = ({ title: i18n.translate('visTypeHeatmap.heatmap.groupTitle', { defaultMessage: 'Y-axis' }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -121,7 +135,14 @@ export const getHeatmapVisTypeDefinition = ({ }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, diff --git a/src/plugins/vis_types/metric/public/metric_vis_type.ts b/src/plugins/vis_types/metric/public/metric_vis_type.ts index d4db2ac9e4671..ffb34248aeccc 100644 --- a/src/plugins/vis_types/metric/public/metric_vis_type.ts +++ b/src/plugins/vis_types/metric/public/metric_vis_type.ts @@ -86,7 +86,14 @@ export const createMetricVisTypeDefinition = (): VisTypeDefinition => }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, diff --git a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts index 15a3675125f61..545456b6dcce0 100644 --- a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts @@ -87,7 +87,14 @@ export const samplePieVis = { title: 'Split slices', min: 0, max: null, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], editor: false, params: [], }, @@ -98,7 +105,14 @@ export const samplePieVis = { mustBeFirst: true, min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [ { name: 'row', diff --git a/src/plugins/vis_types/pie/public/vis_type/pie.ts b/src/plugins/vis_types/pie/public/vis_type/pie.ts index 0d012ed95b5d9..f10af053bd161 100644 --- a/src/plugins/vis_types/pie/public/vis_type/pie.ts +++ b/src/plugins/vis_types/pie/public/vis_type/pie.ts @@ -80,7 +80,14 @@ export const getPieVisTypeDefinition = ({ }), min: 0, max: Infinity, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -91,7 +98,14 @@ export const getPieVisTypeDefinition = ({ mustBeFirst: true, min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, diff --git a/src/plugins/vis_types/table/public/table_vis_type.ts b/src/plugins/vis_types/table/public/table_vis_type.ts index a641224e23f52..2f1642e29107a 100644 --- a/src/plugins/vis_types/table/public/table_vis_type.ts +++ b/src/plugins/vis_types/table/public/table_vis_type.ts @@ -62,7 +62,7 @@ export const tableVisTypeDefinition: VisTypeDefinition = { title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.bucketTitle', { defaultMessage: 'Split rows', }), - aggFilter: ['!filter'], + aggFilter: ['!filter', '!sampler', '!diversified_sampler', '!multi_terms'], }, { group: AggGroupNames.Buckets, @@ -72,7 +72,7 @@ export const tableVisTypeDefinition: VisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!filter'], + aggFilter: ['!filter', '!sampler', '!diversified_sampler', '!multi_terms'], }, ], }, diff --git a/src/plugins/vis_types/vislib/public/gauge.ts b/src/plugins/vis_types/vislib/public/gauge.ts index 51cd7ea7622df..31a44a5d1d73f 100644 --- a/src/plugins/vis_types/vislib/public/gauge.ts +++ b/src/plugins/vis_types/vislib/public/gauge.ts @@ -132,7 +132,14 @@ export const gaugeVisTypeDefinition: VisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, diff --git a/src/plugins/vis_types/vislib/public/goal.ts b/src/plugins/vis_types/vislib/public/goal.ts index 05ad1f53904d7..26bc598790839 100644 --- a/src/plugins/vis_types/vislib/public/goal.ts +++ b/src/plugins/vis_types/vislib/public/goal.ts @@ -96,7 +96,14 @@ export const goalVisTypeDefinition: VisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, diff --git a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.mocks.ts b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.mocks.ts index 41ab13d54f7c6..401afc5a7473a 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.mocks.ts +++ b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.mocks.ts @@ -625,7 +625,14 @@ export const getVis = (bucketType: string) => { title: 'X-axis', min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [], }, { @@ -634,7 +641,14 @@ export const getVis = (bucketType: string) => { title: 'Split series', min: 0, max: 3, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [], }, { @@ -643,7 +657,14 @@ export const getVis = (bucketType: string) => { title: 'Split chart', min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [ { name: 'row', @@ -688,7 +709,14 @@ export const getVis = (bucketType: string) => { title: 'X-axis', min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [], }, { @@ -697,7 +725,14 @@ export const getVis = (bucketType: string) => { title: 'Split series', min: 0, max: 3, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [], }, { @@ -706,7 +741,14 @@ export const getVis = (bucketType: string) => { title: 'Split chart', min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [ { name: 'row', @@ -722,7 +764,14 @@ export const getVis = (bucketType: string) => { title: 'X-axis', min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [], }, { @@ -731,7 +780,14 @@ export const getVis = (bucketType: string) => { title: 'Split series', min: 0, max: 3, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [], }, { @@ -740,7 +796,14 @@ export const getVis = (bucketType: string) => { title: 'Split chart', min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [ { name: 'row', 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 6077732a9cc6b..766929a2cd654 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 @@ -149,7 +149,14 @@ export const sampleAreaVis = { title: 'X-axis', min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], editor: false, params: [], }, @@ -159,7 +166,14 @@ export const sampleAreaVis = { title: 'Split series', min: 0, max: 3, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], editor: false, params: [], }, @@ -169,7 +183,14 @@ export const sampleAreaVis = { title: 'Split chart', min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], params: [ { name: 'row', 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 index 67b8a1c160d40..5c22527d5b9d7 100644 --- 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 @@ -45,7 +45,7 @@ describe('getSeriesParams', () => { ); expect(seriesParams).toStrictEqual([ { - circlesRadius: 3, + circlesRadius: 1, data: { id: '1', label: 'Total quantity', 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 index 987c8df83b01f..0acd2a0913282 100644 --- a/src/plugins/vis_types/xy/public/utils/get_series_params.ts +++ b/src/plugins/vis_types/xy/public/utils/get_series_params.ts @@ -22,7 +22,7 @@ const makeSerie = ( type: ChartType.Line, drawLinesBetweenPoints: true, showCircles: true, - circlesRadius: 3, + circlesRadius: 1, interpolate: InterpolationMode.Linear, lineWidth: 2, valueAxis: defaultValueAxis, 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 3b8f78db25d36..efeb4142ff0d7 100644 --- a/src/plugins/vis_types/xy/public/vis_types/area.ts +++ b/src/plugins/vis_types/xy/public/vis_types/area.ts @@ -97,7 +97,7 @@ export const areaVisTypeDefinition = { drawLinesBetweenPoints: true, lineWidth: 2, showCircles: true, - circlesRadius: 3, + circlesRadius: 1, interpolate: InterpolationMode.Linear, valueAxis: 'ValueAxis-1', }, @@ -157,7 +157,14 @@ export const areaVisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -167,7 +174,14 @@ export const areaVisTypeDefinition = { }), min: 0, max: 3, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -177,7 +191,14 @@ export const areaVisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, 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 79b3fd72de452..1cd346abec6e7 100644 --- a/src/plugins/vis_types/xy/public/vis_types/histogram.ts +++ b/src/plugins/vis_types/xy/public/vis_types/histogram.ts @@ -101,7 +101,7 @@ export const histogramVisTypeDefinition = { drawLinesBetweenPoints: true, lineWidth: 2, showCircles: true, - circlesRadius: 3, + circlesRadius: 1, }, ], radiusRatio: 0, @@ -160,7 +160,14 @@ export const histogramVisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -170,7 +177,14 @@ export const histogramVisTypeDefinition = { }), min: 0, max: 3, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -180,7 +194,14 @@ export const histogramVisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, 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 5ac833190dd38..4e6056bbdae4f 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 @@ -102,7 +102,7 @@ export const horizontalBarVisTypeDefinition = { drawLinesBetweenPoints: true, lineWidth: 2, showCircles: true, - circlesRadius: 3, + circlesRadius: 1, }, ], addTooltip: true, @@ -159,7 +159,14 @@ export const horizontalBarVisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -169,7 +176,14 @@ export const horizontalBarVisTypeDefinition = { }), min: 0, max: 3, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -179,7 +193,14 @@ export const horizontalBarVisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, 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 f7467ca53fa0e..affcc64320df6 100644 --- a/src/plugins/vis_types/xy/public/vis_types/line.ts +++ b/src/plugins/vis_types/xy/public/vis_types/line.ts @@ -99,7 +99,7 @@ export const lineVisTypeDefinition = { lineWidth: 2, interpolate: InterpolationMode.Linear, showCircles: true, - circlesRadius: 3, + circlesRadius: 1, }, ], addTooltip: true, @@ -151,7 +151,14 @@ export const lineVisTypeDefinition = { title: i18n.translate('visTypeXy.line.segmentTitle', { defaultMessage: 'X-axis' }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -161,7 +168,14 @@ export const lineVisTypeDefinition = { }), min: 0, max: 3, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, { group: AggGroupNames.Buckets, @@ -171,7 +185,14 @@ export const lineVisTypeDefinition = { }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!multi_terms'], + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!multi_terms', + ], }, ], }, diff --git a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap index 56e2cb1b60f3c..98d37568e4541 100644 --- a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap +++ b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap @@ -6,29 +6,42 @@ exports[`VisualizationNoResults should render according to snapshot 1`] = ` data-test-subj="visNoResult" >
-
-
+ +
+
- No results found + +
+
+ No results found +
+
+
-
+
`; diff --git a/src/plugins/visualizations/public/utils/saved_objects_utils/check_for_duplicate_title.ts b/src/plugins/visualizations/public/utils/saved_objects_utils/check_for_duplicate_title.ts new file mode 100644 index 0000000000000..c0820cce45c90 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/check_for_duplicate_title.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 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 { OverlayStart, SavedObjectsClientContract } from 'kibana/public'; +import type { SavedObject } from '../../../../saved_objects/public'; +import { SAVE_DUPLICATE_REJECTED } from './constants'; +import { findObjectByTitle } from './find_object_by_title'; +import { displayDuplicateTitleConfirmModal } from './display_duplicate_title_confirm_modal'; + +/** + * check for an existing SavedObject with the same title in ES + * returns Promise when it's no duplicate, or the modal displaying the warning + * that's there's a duplicate is confirmed, else it returns a rejected Promise + * @param savedObject + * @param isTitleDuplicateConfirmed + * @param onTitleDuplicate + * @param services + */ +export async function checkForDuplicateTitle( + savedObject: Pick< + SavedObject, + 'id' | 'title' | 'getDisplayName' | 'lastSavedTitle' | 'copyOnSave' | 'getEsType' + >, + isTitleDuplicateConfirmed: boolean, + onTitleDuplicate: (() => void) | undefined, + services: { + savedObjectsClient: SavedObjectsClientContract; + overlays: OverlayStart; + } +): Promise { + const { savedObjectsClient, overlays } = services; + // Don't check for duplicates if user has already confirmed save with duplicate title + if (isTitleDuplicateConfirmed) { + return true; + } + + // Don't check if the user isn't updating the title, otherwise that would become very annoying to have + // to confirm the save every time, except when copyOnSave is true, then we do want to check. + if (savedObject.title === savedObject.lastSavedTitle && !savedObject.copyOnSave) { + return true; + } + + const duplicate = await findObjectByTitle( + savedObjectsClient, + savedObject.getEsType(), + savedObject.title + ); + + if (!duplicate || duplicate.id === savedObject.id) { + return true; + } + + if (onTitleDuplicate) { + onTitleDuplicate(); + return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED)); + } + + // TODO: make onTitleDuplicate a required prop and remove UI components from this class + // Need to leave here until all users pass onTitleDuplicate. + return displayDuplicateTitleConfirmModal(savedObject, overlays); +} diff --git a/src/plugins/visualizations/public/utils/saved_objects_utils/confirm_modal_promise.tsx b/src/plugins/visualizations/public/utils/saved_objects_utils/confirm_modal_promise.tsx new file mode 100644 index 0000000000000..3c29fd958465b --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/confirm_modal_promise.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import type { OverlayStart } from 'kibana/public'; +import { EuiConfirmModal } from '@elastic/eui'; +import { toMountPoint } from '../../../../kibana_react/public'; + +export function confirmModalPromise( + message = '', + title = '', + confirmBtnText = '', + overlays: OverlayStart +): Promise { + return new Promise((resolve, reject) => { + const cancelButtonText = i18n.translate('visualizations.confirmModal.cancelButtonLabel', { + defaultMessage: 'Cancel', + }); + + const modal = overlays.openModal( + toMountPoint( + { + modal.close(); + reject(); + }} + onConfirm={() => { + modal.close(); + resolve(true); + }} + confirmButtonText={confirmBtnText} + cancelButtonText={cancelButtonText} + title={title} + > + {message} + + ) + ); + }); +} diff --git a/src/plugins/visualizations/public/utils/saved_objects_utils/constants.ts b/src/plugins/visualizations/public/utils/saved_objects_utils/constants.ts new file mode 100644 index 0000000000000..fcabc0b493f68 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/constants.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'; + +/** An error message to be used when the user rejects a confirm overwrite. */ +export const OVERWRITE_REJECTED = i18n.translate('visualizations.overwriteRejectedDescription', { + defaultMessage: 'Overwrite confirmation was rejected', +}); + +/** An error message to be used when the user rejects a confirm save with duplicate title. */ +export const SAVE_DUPLICATE_REJECTED = i18n.translate( + 'visualizations.saveDuplicateRejectedDescription', + { + defaultMessage: 'Save with duplicate title confirmation was rejected', + } +); diff --git a/src/plugins/visualizations/public/utils/saved_objects_utils/display_duplicate_title_confirm_modal.ts b/src/plugins/visualizations/public/utils/saved_objects_utils/display_duplicate_title_confirm_modal.ts new file mode 100644 index 0000000000000..48ada48511812 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/display_duplicate_title_confirm_modal.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 { i18n } from '@kbn/i18n'; +import type { OverlayStart } from 'kibana/public'; +import type { SavedObject } from '../../../../saved_objects/public'; +import { SAVE_DUPLICATE_REJECTED } from './constants'; +import { confirmModalPromise } from './confirm_modal_promise'; + +export function displayDuplicateTitleConfirmModal( + savedObject: Pick, + overlays: OverlayStart +): Promise { + const confirmMessage = i18n.translate( + 'visualizations.confirmModal.saveDuplicateConfirmationMessage', + { + defaultMessage: `A {name} with the title '{title}' already exists. Would you like to save anyway?`, + values: { title: savedObject.title, name: savedObject.getDisplayName() }, + } + ); + + const confirmButtonText = i18n.translate('visualizations.confirmModal.saveDuplicateButtonLabel', { + defaultMessage: 'Save {name}', + values: { name: savedObject.getDisplayName() }, + }); + try { + return confirmModalPromise(confirmMessage, '', confirmButtonText, overlays); + } catch { + return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED)); + } +} diff --git a/src/plugins/visualizations/public/utils/saved_objects_utils/find_object_by_title.test.ts b/src/plugins/visualizations/public/utils/saved_objects_utils/find_object_by_title.test.ts new file mode 100644 index 0000000000000..d61fe1c13eee4 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/find_object_by_title.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { findObjectByTitle } from './find_object_by_title'; +import { + SimpleSavedObject, + SavedObjectsClientContract, + SavedObject, +} from '../../../../../core/public'; + +describe('findObjectByTitle', () => { + const savedObjectsClient: SavedObjectsClientContract = {} as SavedObjectsClientContract; + + beforeEach(() => { + savedObjectsClient.find = jest.fn(); + }); + + it('returns undefined if title is not provided', async () => { + const match = await findObjectByTitle(savedObjectsClient, 'index-pattern', ''); + expect(match).toBeUndefined(); + }); + + it('matches any case', async () => { + const indexPattern = new SimpleSavedObject(savedObjectsClient, { + attributes: { title: 'foo' }, + } as SavedObject); + savedObjectsClient.find = jest.fn().mockImplementation(() => + Promise.resolve({ + savedObjects: [indexPattern], + }) + ); + const match = await findObjectByTitle(savedObjectsClient, 'index-pattern', 'FOO'); + expect(match).toEqual(indexPattern); + }); +}); diff --git a/src/plugins/visualizations/public/utils/saved_objects_utils/find_object_by_title.ts b/src/plugins/visualizations/public/utils/saved_objects_utils/find_object_by_title.ts new file mode 100644 index 0000000000000..10289ac0f2f53 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/find_object_by_title.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + SavedObjectsClientContract, + SimpleSavedObject, + SavedObjectAttributes, +} from 'kibana/public'; + +/** Returns an object matching a given title */ +export async function findObjectByTitle( + savedObjectsClient: SavedObjectsClientContract, + type: string, + title: string +): Promise | void> { + if (!title) { + return; + } + + // Elastic search will return the most relevant results first, which means exact matches should come + // first, and so we shouldn't need to request everything. Using 10 just to be on the safe side. + const response = await savedObjectsClient.find({ + type, + perPage: 10, + search: `"${title}"`, + searchFields: ['title'], + fields: ['title'], + }); + return response.savedObjects.find( + (obj) => obj.get('title').toLowerCase() === title.toLowerCase() + ); +} diff --git a/src/plugins/data/public/query/filter_manager/types.ts b/src/plugins/visualizations/public/utils/saved_objects_utils/index.ts similarity index 72% rename from src/plugins/data/public/query/filter_manager/types.ts rename to src/plugins/visualizations/public/utils/saved_objects_utils/index.ts index 5c2667fbf1d2a..e993ddd96a7d9 100644 --- a/src/plugins/data/public/query/filter_manager/types.ts +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/index.ts @@ -6,9 +6,5 @@ * Side Public License, v 1. */ -import { Filter } from '../../../common'; - -export interface PartitionedFilters { - globalFilters: Filter[]; - appFilters: Filter[]; -} +export { saveWithConfirmation } from './save_with_confirmation'; +export { checkForDuplicateTitle } from './check_for_duplicate_title'; diff --git a/src/plugins/visualizations/public/utils/saved_objects_utils/save_with_confirmation.test.ts b/src/plugins/visualizations/public/utils/saved_objects_utils/save_with_confirmation.test.ts new file mode 100644 index 0000000000000..6d2c8f6bbe089 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/save_with_confirmation.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { SavedObjectAttributes, SavedObjectsCreateOptions, OverlayStart } from 'kibana/public'; +import type { SavedObjectsClientContract } from 'kibana/public'; +import { saveWithConfirmation } from './save_with_confirmation'; +import * as deps from './confirm_modal_promise'; +import { OVERWRITE_REJECTED } from './constants'; + +describe('saveWithConfirmation', () => { + const savedObjectsClient: SavedObjectsClientContract = {} as SavedObjectsClientContract; + const overlays: OverlayStart = {} as OverlayStart; + const source: SavedObjectAttributes = {} as SavedObjectAttributes; + const options: SavedObjectsCreateOptions = {} as SavedObjectsCreateOptions; + const savedObject = { + getEsType: () => 'test type', + title: 'test title', + displayName: 'test display name', + }; + + beforeEach(() => { + savedObjectsClient.create = jest.fn(); + jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.resolve({} as any)); + }); + + test('should call create of savedObjectsClient', async () => { + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + savedObject.getEsType(), + source, + options + ); + }); + + test('should call confirmModalPromise when such record exists', async () => { + savedObjectsClient.create = jest + .fn() + .mockImplementation((type, src, opt) => + opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) + ); + + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(deps.confirmModalPromise).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + overlays + ); + }); + + test('should call create of savedObjectsClient when overwriting confirmed', async () => { + savedObjectsClient.create = jest + .fn() + .mockImplementation((type, src, opt) => + opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) + ); + + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenLastCalledWith(savedObject.getEsType(), source, { + overwrite: true, + ...options, + }); + }); + + test('should reject when overwriting denied', async () => { + savedObjectsClient.create = jest.fn().mockReturnValue(Promise.reject({ res: { status: 409 } })); + jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.reject()); + + expect.assertions(1); + await expect( + saveWithConfirmation(source, savedObject, options, { + savedObjectsClient, + overlays, + }) + ).rejects.toThrow(OVERWRITE_REJECTED); + }); +}); diff --git a/src/plugins/visualizations/public/utils/saved_objects_utils/save_with_confirmation.ts b/src/plugins/visualizations/public/utils/saved_objects_utils/save_with_confirmation.ts new file mode 100644 index 0000000000000..de9ba38343548 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_objects_utils/save_with_confirmation.ts @@ -0,0 +1,76 @@ +/* + * Copyright 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 { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import type { + SavedObjectAttributes, + SavedObjectsCreateOptions, + OverlayStart, + SavedObjectsClientContract, +} from 'kibana/public'; +import { OVERWRITE_REJECTED } from './constants'; +import { confirmModalPromise } from './confirm_modal_promise'; + +/** + * Attempts to create the current object using the serialized source. If an object already + * exists, a warning message requests an overwrite confirmation. + * @param source - serialized version of this object what will be indexed into elasticsearch. + * @param savedObject - a simple object that contains properties title and displayName, and getEsType method + * @param options - options to pass to the saved object create method + * @param services - provides Kibana services savedObjectsClient and overlays + * @returns {Promise} - A promise that is resolved with the objects id if the object is + * successfully indexed. If the overwrite confirmation was rejected, an error is thrown with + * a confirmRejected = true parameter so that case can be handled differently than + * a create or index error. + * @resolved {SavedObject} + */ +export async function saveWithConfirmation( + source: SavedObjectAttributes, + savedObject: { + getEsType(): string; + title: string; + displayName: string; + }, + options: SavedObjectsCreateOptions, + services: { savedObjectsClient: SavedObjectsClientContract; overlays: OverlayStart } +) { + const { savedObjectsClient, overlays } = services; + try { + return await savedObjectsClient.create(savedObject.getEsType(), source, options); + } catch (err) { + // record exists, confirm overwriting + if (get(err, 'res.status') === 409) { + const confirmMessage = i18n.translate( + 'visualizations.confirmModal.overwriteConfirmationMessage', + { + defaultMessage: 'Are you sure you want to overwrite {title}?', + values: { title: savedObject.title }, + } + ); + + const title = i18n.translate('visualizations.confirmModal.overwriteTitle', { + defaultMessage: 'Overwrite {name}?', + values: { name: savedObject.displayName }, + }); + const confirmButtonText = i18n.translate('visualizations.confirmModal.overwriteButtonLabel', { + defaultMessage: 'Overwrite', + }); + + return confirmModalPromise(confirmMessage, title, confirmButtonText, overlays) + .then(() => + savedObjectsClient.create(savedObject.getEsType(), source, { + overwrite: true, + ...options, + }) + ) + .catch(() => Promise.reject(new Error(OVERWRITE_REJECTED))); + } + return await Promise.reject(err); + } +} diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts index 5c8c0594d3563..fe2453fbb78a4 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts @@ -56,10 +56,11 @@ const mockCheckForDuplicateTitle = jest.fn(() => { } }); const mockSaveWithConfirmation = jest.fn(() => ({ id: 'test-after-confirm' })); -jest.mock('../../../../plugins/saved_objects/public', () => ({ +jest.mock('./saved_objects_utils/check_for_duplicate_title', () => ({ checkForDuplicateTitle: jest.fn(() => mockCheckForDuplicateTitle()), +})); +jest.mock('./saved_objects_utils/save_with_confirmation', () => ({ saveWithConfirmation: jest.fn(() => mockSaveWithConfirmation()), - isErrorNonFatal: jest.fn(() => true), })); describe('saved_visualize_utils', () => { @@ -263,15 +264,19 @@ describe('saved_visualize_utils', () => { describe('isTitleDuplicateConfirmed', () => { it('as false we should not save vis with duplicated title', async () => { isTitleDuplicateConfirmed = false; - const savedVisId = await saveVisualization( - vis, - { isTitleDuplicateConfirmed }, - { savedObjectsClient, overlays } - ); + try { + const savedVisId = await saveVisualization( + vis, + { isTitleDuplicateConfirmed }, + { savedObjectsClient, overlays } + ); + expect(savedVisId).toBe(''); + } catch { + // ignore + } expect(savedObjectsClient.create).not.toHaveBeenCalled(); expect(mockSaveWithConfirmation).not.toHaveBeenCalled(); expect(mockCheckForDuplicateTitle).toHaveBeenCalled(); - expect(savedVisId).toBe(''); expect(vis.id).toBeUndefined(); }); diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index a28ee9486c4d2..f221fa6a208b8 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -22,11 +22,7 @@ import { parseSearchSourceJSON, DataPublicPluginStart, } from '../../../../plugins/data/public'; -import { - checkForDuplicateTitle, - saveWithConfirmation, - isErrorNonFatal, -} from '../../../../plugins/saved_objects/public'; +import { saveWithConfirmation, checkForDuplicateTitle } from './saved_objects_utils'; import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public'; import { VisualizationsAppExtension } from '../vis_types/vis_type_alias_registry'; @@ -41,6 +37,7 @@ import type { TypesStart, BaseVisType } from '../vis_types'; // @ts-ignore import { updateOldState } from '../legacy/vis_update_state'; import { injectReferences, extractReferences } from './saved_visualization_references'; +import { OVERWRITE_REJECTED, SAVE_DUPLICATE_REJECTED } from './saved_objects_utils/constants'; export const SAVED_VIS_TYPE = 'visualization'; @@ -395,7 +392,7 @@ export async function saveVisualization( return savedObject.id; } catch (err: any) { savedObject.id = originalId; - if (isErrorNonFatal(err)) { + if (err && [OVERWRITE_REJECTED, SAVE_DUPLICATE_REJECTED].includes(err.message)) { return ''; } return Promise.reject(err); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 1f6fbfeb47e59..06e06a4fefa0c 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -1674,7 +1674,8 @@ describe('migration visualization', () => { type = 'area', categoryAxes?: object[], valueAxes?: object[], - hasPalette = false + hasPalette = false, + hasCirclesRadius = false ) => ({ attributes: { title: 'My Vis', @@ -1694,6 +1695,21 @@ describe('migration visualization', () => { labels: {}, }, ], + seriesParams: [ + { + show: true, + type, + mode: 'stacked', + drawLinesBetweenPoints: true, + lineWidth: 2, + showCircles: true, + interpolate: 'linear', + valueAxis: 'ValueAxis-1', + ...(hasCirclesRadius && { + circlesRadius: 3, + }), + }, + ], ...(hasPalette && { palette: { type: 'palette', @@ -1732,6 +1748,20 @@ describe('migration visualization', () => { expect(palette.name).toEqual('default'); }); + it("should decorate existing docs with the circlesRadius attribute if it doesn't exist", () => { + const migratedTestDoc = migrate(getTestDoc()); + const [result] = JSON.parse(migratedTestDoc.attributes.visState).params.seriesParams; + + expect(result.circlesRadius).toEqual(1); + }); + + it('should not decorate existing docs with the circlesRadius attribute if it exists', () => { + const migratedTestDoc = migrate(getTestDoc('area', undefined, undefined, true, true)); + const [result] = JSON.parse(migratedTestDoc.attributes.visState).params.seriesParams; + + expect(result.circlesRadius).toEqual(3); + }); + describe('labels.filter', () => { it('should keep existing categoryAxes labels.filter value', () => { const migratedTestDoc = migrate(getTestDoc('area', [{ labels: { filter: false } }])); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index b598d34943e6c..4c8771a2f6924 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -867,6 +867,20 @@ const decorateAxes = ( }, })); +/** + * Defaults circlesRadius to 1 if it is not configured + */ +const addCirclesRadius = (axes: T[]): T[] => + axes.map((axis) => { + const hasCircleRadiusAttribute = Number.isFinite(axis?.circlesRadius); + return { + ...axis, + ...(!hasCircleRadiusAttribute && { + circlesRadius: 1, + }), + }; + }); + // Inlined from vis_type_xy const CHART_TYPE_AREA = 'area'; const CHART_TYPE_LINE = 'line'; @@ -913,10 +927,12 @@ const migrateVislibAreaLineBarTypes: SavedObjectMigrationFn = (doc) => valueAxes: visState.params.valueAxes && decorateAxes(visState.params.valueAxes, isHorizontalBar), + seriesParams: + visState.params.seriesParams && addCirclesRadius(visState.params.seriesParams), isVislibVis: true, detailedTooltip: true, ...(isLineOrArea && { - fittingFunction: 'zero', + fittingFunction: 'linear', }), }, }), diff --git a/test/common/config.js b/test/common/config.js index b9ab24450ac82..1a60932581847 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -56,6 +56,10 @@ export default function () { ...(!!process.env.CODE_COVERAGE ? [`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'coverage')}`] : []), + '--logging.appenders.deprecation.type=console', + '--logging.appenders.deprecation.layout.type=json', + '--logging.loggers[0].name=elasticsearch.deprecation', + '--logging.loggers[0].appenders[0]=deprecation', ], }, services, diff --git a/test/functional/apps/context/_context_navigation.ts b/test/functional/apps/context/_context_navigation.ts index 9b8d33208dfb1..c7337b91bf0c9 100644 --- a/test/functional/apps/context/_context_navigation.ts +++ b/test/functional/apps/context/_context_navigation.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; const TEST_FILTER_COLUMN_NAMES = [ @@ -22,6 +23,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const docTable = getService('docTable'); const PageObjects = getPageObjects(['common', 'context', 'discover', 'timePicker']); const kibanaServer = getService('kibanaServer'); + const filterBar = getService('filterBar'); + const find = getService('find'); describe('discover - context - back navigation', function contextSize() { before(async function () { @@ -56,5 +59,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return initialHitCount === hitCount; }); }); + + it('should go back via breadcrumbs with preserved state', async function () { + await retry.waitFor( + 'user navigating to context and returning to discover via breadcrumbs', + async () => { + await docTable.clickRowToggle({ rowIndex: 0 }); + const rowActions = await docTable.getRowActions({ rowIndex: 0 }); + await rowActions[0].click(); + await PageObjects.context.waitUntilContextLoadingHasFinished(); + + await find.clickByCssSelector(`[data-test-subj="breadcrumb first"]`); + await PageObjects.discover.waitForDocTableLoadingComplete(); + + for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) { + expect(await filterBar.hasFilter(columnName, value)).to.eql(true); + } + expect(await PageObjects.timePicker.getTimeConfigAsAbsoluteTimes()).to.eql({ + start: 'Sep 18, 2015 @ 06:31:44.000', + end: 'Sep 23, 2015 @ 18:31:44.000', + }); + return true; + } + ); + }); }); } diff --git a/test/functional/apps/dashboard/dashboard_filtering.ts b/test/functional/apps/dashboard/dashboard_filtering.ts index 796e8e35f0d49..6c8a378831340 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.ts +++ b/test/functional/apps/dashboard/dashboard_filtering.ts @@ -29,8 +29,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize', 'timePicker']); describe('dashboard filtering', function () { - this.tags('includeFirefox'); - const populateDashboard = async () => { await PageObjects.dashboard.clickNewDashboard(); await PageObjects.timePicker.setDefaultDataRange(); @@ -67,8 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await security.testUser.restoreDefaults(); }); - // FLAKY: https://github.com/elastic/kibana/issues/120195 - describe.skip('adding a filter that excludes all data', () => { + describe('adding a filter that excludes all data', () => { before(async () => { await populateDashboard(); await addFilterAndRefresh(); diff --git a/test/functional/apps/dashboard/full_screen_mode.ts b/test/functional/apps/dashboard/full_screen_mode.ts index 02669759f68ea..fcfd0fc49dd2b 100644 --- a/test/functional/apps/dashboard/full_screen_mode.ts +++ b/test/functional/apps/dashboard/full_screen_mode.ts @@ -12,6 +12,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); + const browser = getService('browser'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); @@ -93,5 +94,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await filterBar.removeFilter('bytes'); }); + + it('exits full screen mode when back button pressed', async () => { + await PageObjects.dashboard.clickFullScreenMode(); + await browser.goBack(); + await retry.try(async () => { + const isChromeVisible = await PageObjects.common.isChromeVisible(); + expect(isChromeVisible).to.be(true); + }); + + await browser.goForward(); + await retry.try(async () => { + const isChromeVisible = await PageObjects.common.isChromeVisible(); + expect(isChromeVisible).to.be(true); + }); + }); }); } diff --git a/test/functional/apps/dashboard/url_field_formatter.ts b/test/functional/apps/dashboard/url_field_formatter.ts index 254d71294d8c7..16cdb62768219 100644 --- a/test/functional/apps/dashboard/url_field_formatter.ts +++ b/test/functional/apps/dashboard/url_field_formatter.ts @@ -56,7 +56,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await common.navigateToApp('dashboard'); await dashboard.loadSavedDashboard('dashboard with table'); await dashboard.waitForRenderComplete(); - const fieldLink = await visChart.getFieldLinkInVisTable(`${fieldName}: Descending`, 1); + const fieldLink = await visChart.getFieldLinkInVisTable(`${fieldName}: Descending`); await clickFieldAndCheckUrl(fieldLink); }); diff --git a/test/functional/apps/discover/_data_grid_field_data.ts b/test/functional/apps/discover/_data_grid_field_data.ts index 91c2d5914732d..4a4e06e28c321 100644 --- a/test/functional/apps/discover/_data_grid_field_data.ts +++ b/test/functional/apps/discover/_data_grid_field_data.ts @@ -71,7 +71,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.waitUntilSearchingHasFinished(); await retry.waitFor('first cell contains expected timestamp', async () => { - const cell = await dataGrid.getCellElement(1, 3); + const cell = await dataGrid.getCellElement(0, 2); const text = await cell.getVisibleText(); return text === expectedTimeStamp; }); diff --git a/test/functional/apps/visualize/_data_table.ts b/test/functional/apps/visualize/_data_table.ts index 14181c084a77f..77973b8fb9b67 100644 --- a/test/functional/apps/visualize/_data_table.ts +++ b/test/functional/apps/visualize/_data_table.ts @@ -268,7 +268,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should apply correct filter', async () => { - await PageObjects.visChart.filterOnTableCell(1, 3); + await PageObjects.visChart.filterOnTableCell(0, 2); await PageObjects.visChart.waitForVisualizationRenderingStabilized(); const data = await PageObjects.visChart.getTableVisContent(); expect(data).to.be.eql([ diff --git a/test/functional/apps/visualize/_data_table_notimeindex_filters.ts b/test/functional/apps/visualize/_data_table_notimeindex_filters.ts index ef664bf4b3054..51ceef947bfac 100644 --- a/test/functional/apps/visualize/_data_table_notimeindex_filters.ts +++ b/test/functional/apps/visualize/_data_table_notimeindex_filters.ts @@ -70,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await retry.try(async () => { // hover and click on cell to filter - await PageObjects.visChart.filterOnTableCell(1, 2); + await PageObjects.visChart.filterOnTableCell(0, 1); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); diff --git a/test/functional/apps/visualize/_embedding_chart.ts b/test/functional/apps/visualize/_embedding_chart.ts index 93ab2987dc4a8..9531eafc33bed 100644 --- a/test/functional/apps/visualize/_embedding_chart.ts +++ b/test/functional/apps/visualize/_embedding_chart.ts @@ -83,7 +83,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should allow to change timerange from the visualization in embedded mode', async () => { await retry.try(async () => { - await PageObjects.visChart.filterOnTableCell(1, 7); + await PageObjects.visChart.filterOnTableCell(0, 6); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index d9f183ddd5332..dc36197034691 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -349,10 +349,12 @@ export class VisualizeChartPageObject extends FtrService { return await this.testSubjects.getVisibleText('dataGridHeader'); } - public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { - const headers = await this.dataGrid.getHeaders(); - const fieldColumnIndex = headers.indexOf(fieldName); - const cell = await this.dataGrid.getCellElement(rowIndex, fieldColumnIndex + 1); + public async getFieldLinkInVisTable( + fieldName: string, + rowIndex: number = 0, + colIndex: number = 0 + ) { + const cell = await this.dataGrid.getCellElement(rowIndex, colIndex); return await cell.findByTagName('a'); } diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index f54e7b65a46e2..d49ef5fa0990a 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -81,18 +81,12 @@ export class DataGridService extends FtrService { /** * Returns a grid cell element by row & column indexes. - * The row offset equals 1 since the first row of data grid is the header row. - * @param rowIndex data row index starting from 1 (1 means 1st row) - * @param columnIndex column index starting from 1 (1 means 1st column) + * @param rowIndex data row index starting from 0 (0 means 1st row) + * @param columnIndex column index starting from 0 (0 means 1st column) */ - public async getCellElement(rowIndex: number, columnIndex: number) { - const table = await this.find.byCssSelector('.euiDataGrid'); - const $ = await table.parseDomContent(); - const columnNumber = $('.euiDataGridHeaderCell__content').length; + public async getCellElement(rowIndex: number = 0, columnIndex: number = 0) { return await this.find.byCssSelector( - `[data-test-subj="dataGridWrapper"] [data-test-subj="dataGridRowCell"]:nth-of-type(${ - columnNumber * (rowIndex - 1) + columnIndex + 1 - })` + `[data-test-subj="dataGridWrapper"] [data-test-subj="dataGridRowCell"][data-gridcell-id="${rowIndex},${columnIndex}"]` ); } diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs_sampler.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs_sampler.ts new file mode 100644 index 0000000000000..d2411b2416067 --- /dev/null +++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs_sampler.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { ExpectExpression, expectExpressionProvider } from './helpers'; +import { FtrProviderContext } from '../../../functional/ftr_provider_context'; + +export default function ({ + getService, + updateBaselines, +}: FtrProviderContext & { updateBaselines: boolean }) { + let expectExpression: ExpectExpression; + + describe('esaggs_sampler', () => { + before(() => { + expectExpression = expectExpressionProvider({ getService, updateBaselines }); + }); + + const timeRange = { + from: '2015-09-21T00:00:00Z', + to: '2015-09-22T00:00:00Z', + }; + + describe('aggSampler', () => { + it('can execute aggSampler', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggSampler id="0" enabled=true schema="bucket"} + aggs={aggAvg id="1" enabled=true schema="metric" field="bytes"} + `; + const result = await expectExpression('sampler', expression).getResponse(); + + expect(result.columns.length).to.be(2); + const samplerColumn = result.columns[0]; + expect(samplerColumn.name).to.be('sampler'); + expect(samplerColumn.meta.sourceParams.params).to.eql({}); + + expect(result.rows.length).to.be(1); + expect(Object.keys(result.rows[0]).length).to.be(1); + const resultFromSample = result.rows[0]['col-1-1']; // check that sampler bucket doesn't produce columns + expect(typeof resultFromSample).to.be('number'); + expect(resultFromSample).to.greaterThan(0); // can't check exact metric using sample + }); + + it('can execute aggSampler with custom shard_size', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggSampler id="0" enabled=true schema="bucket" shard_size=20} + aggs={aggAvg id="1" enabled=true schema="metric" field="bytes"} + `; + const result = await expectExpression('sampler', expression).getResponse(); + + expect(result.columns.length).to.be(2); + const samplerColumn = result.columns[0]; + expect(samplerColumn.name).to.be('sampler'); + expect(samplerColumn.meta.sourceParams.params).to.eql({ shard_size: 20 }); + + expect(result.rows.length).to.be(1); + expect(Object.keys(result.rows[0]).length).to.be(1); // check that sampler bucket doesn't produce columns + const resultFromSample = result.rows[0]['col-1-1']; + expect(typeof resultFromSample).to.be('number'); + expect(resultFromSample).to.greaterThan(0); // can't check exact metric using sample + }); + }); + + describe('aggDiversifiedSampler', () => { + it('can execute aggDiversifiedSampler', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggDiversifiedSampler id="0" enabled=true schema="bucket" field="extension.raw"} + aggs={aggAvg id="1" enabled=true schema="metric" field="bytes"} + `; + const result = await expectExpression('sampler', expression).getResponse(); + + expect(result.columns.length).to.be(2); + const samplerColumn = result.columns[0]; + expect(samplerColumn.name).to.be('diversified_sampler'); + expect(samplerColumn.meta.sourceParams.params).to.eql({ field: 'extension.raw' }); + + expect(result.rows.length).to.be(1); + expect(Object.keys(result.rows[0]).length).to.be(1); + const resultFromSample = result.rows[0]['col-1-1']; // check that sampler bucket doesn't produce columns + expect(typeof resultFromSample).to.be('number'); + expect(resultFromSample).to.greaterThan(0); // can't check exact metric using sample + }); + + it('can execute aggSampler with custom shard_size and max_docs_per_value', async () => { + const expression = ` + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} + | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggDiversifiedSampler id="0" enabled=true schema="bucket" field="extension.raw" shard_size=20 max_docs_per_value=3} + aggs={aggAvg id="1" enabled=true schema="metric" field="bytes"} + `; + const result = await expectExpression('sampler', expression).getResponse(); + + expect(result.columns.length).to.be(2); + const samplerColumn = result.columns[0]; + expect(samplerColumn.name).to.be('diversified_sampler'); + expect(samplerColumn.meta.sourceParams.params).to.eql({ + field: 'extension.raw', + max_docs_per_value: 3, + shard_size: 20, + }); + + expect(result.rows.length).to.be(1); + expect(Object.keys(result.rows[0]).length).to.be(1); // check that sampler bucket doesn't produce columns + const resultFromSample = result.rows[0]['col-1-1']; + expect(typeof resultFromSample).to.be('number'); + expect(resultFromSample).to.greaterThan(0); // can't check exact metric using sample + }); + }); + }); +} diff --git a/test/interpreter_functional/test_suites/run_pipeline/index.ts b/test/interpreter_functional/test_suites/run_pipeline/index.ts index 32f59fcf3df9c..fe2ccce23d94a 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/index.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/index.ts @@ -44,5 +44,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid loadTestFile(require.resolve('./esaggs')); loadTestFile(require.resolve('./esaggs_timeshift')); loadTestFile(require.resolve('./esaggs_multiterms')); + loadTestFile(require.resolve('./esaggs_sampler')); }); } diff --git a/tsconfig.base.json b/tsconfig.base.json index ae06e39f7a274..71d686e09d162 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,23 +7,21 @@ "node_modules/@kbn/*", "bazel-out/darwin-fastbuild/bin/packages/kbn-*", "bazel-out/k8-fastbuild/bin/packages/kbn-*", - "bazel-out/x64_windows-fastbuild/bin/packages/kbn-*", + "bazel-out/x64_windows-fastbuild/bin/packages/kbn-*" ], // Allows for importing from `kibana` package for the exported types. "kibana": ["./kibana"], "kibana/public": ["src/core/public"], "kibana/server": ["src/core/server"], - "@emotion/core": [ - "typings/@emotion" - ], - "resize-observer-polyfill": [ - "typings/resize-observer-polyfill" - ] + "@emotion/core": ["typings/@emotion"], + "resize-observer-polyfill": ["typings/resize-observer-polyfill"] }, // Support .tsx files and transform JSX into calls to React.createElement "jsx": "react", // Enables all strict type checking options. "strict": true, + // for now, don't use unknown in catch + "useUnknownInCatchVariables": false, // All TS projects should be composite and only include the files they select, and ref the files outside of the project "composite": true, // save information about the project graph on disk diff --git a/x-pack/examples/alerting_example/public/components/view_alert.tsx b/x-pack/examples/alerting_example/public/components/view_alert.tsx index 0269654806c51..735dcd0899814 100644 --- a/x-pack/examples/alerting_example/public/components/view_alert.tsx +++ b/x-pack/examples/alerting_example/public/components/view_alert.tsx @@ -24,7 +24,7 @@ import { isEmpty } from 'lodash'; import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; import { Alert, - AlertTaskState, + RuleTaskState, LEGACY_BASE_ALERT_API_PATH, } from '../../../../plugins/alerting/common'; @@ -34,7 +34,7 @@ type Props = RouteComponentProps & { }; export const ViewAlertPage = withRouter(({ http, id }: Props) => { const [alert, setAlert] = useState(null); - const [alertState, setAlertState] = useState(null); + const [alertState, setAlertState] = useState(null); useEffect(() => { if (!alert) { @@ -42,7 +42,7 @@ export const ViewAlertPage = withRouter(({ http, id }: Props) => { } if (!alertState) { http - .get(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/state`) + .get(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/state`) .then(setAlertState); } }, [alert, alertState, http, id]); diff --git a/x-pack/examples/alerting_example/public/components/view_astros_alert.tsx b/x-pack/examples/alerting_example/public/components/view_astros_alert.tsx index 44ca8f624c197..282256601547d 100644 --- a/x-pack/examples/alerting_example/public/components/view_astros_alert.tsx +++ b/x-pack/examples/alerting_example/public/components/view_astros_alert.tsx @@ -26,7 +26,7 @@ import { isEmpty } from 'lodash'; import { ALERTING_EXAMPLE_APP_ID, AlwaysFiringParams } from '../../common/constants'; import { Alert, - AlertTaskState, + RuleTaskState, LEGACY_BASE_ALERT_API_PATH, } from '../../../../plugins/alerting/common'; @@ -40,7 +40,7 @@ function hasCraft(state: any): state is { craft: string } { } export const ViewPeopleInSpaceAlertPage = withRouter(({ http, id }: Props) => { const [alert, setAlert] = useState | null>(null); - const [alertState, setAlertState] = useState(null); + const [alertState, setAlertState] = useState(null); useEffect(() => { if (!alert) { @@ -50,7 +50,7 @@ export const ViewPeopleInSpaceAlertPage = withRouter(({ http, id }: Props) => { } if (!alertState) { http - .get(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/state`) + .get(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/state`) .then(setAlertState); } }, [alert, alertState, http, id]); diff --git a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts index 974fb8bf35ae0..dc89a473a38ab 100644 --- a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts +++ b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts @@ -7,7 +7,7 @@ import uuid from 'uuid'; import { range } from 'lodash'; -import { AlertType } from '../../../../plugins/alerting/server'; +import { RuleType } from '../../../../plugins/alerting/server'; import { DEFAULT_INSTANCES_TO_GENERATE, ALERTING_EXAMPLE_APP_ID, @@ -37,7 +37,7 @@ function getTShirtSizeByIdAndThreshold( return DEFAULT_ACTION_GROUP; } -export const alertType: AlertType< +export const alertType: RuleType< AlwaysFiringParams, never, { count?: number }, diff --git a/x-pack/examples/alerting_example/server/alert_types/astros.ts b/x-pack/examples/alerting_example/server/alert_types/astros.ts index 93bdeb2eada9c..c5d4af6872c83 100644 --- a/x-pack/examples/alerting_example/server/alert_types/astros.ts +++ b/x-pack/examples/alerting_example/server/alert_types/astros.ts @@ -6,7 +6,7 @@ */ import axios from 'axios'; -import { AlertType } from '../../../../plugins/alerting/server'; +import { RuleType } from '../../../../plugins/alerting/server'; import { Operator, Craft, ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; interface PeopleInSpace { @@ -39,7 +39,7 @@ function getCraftFilter(craft: string) { craft === Craft.OuterSpace ? true : craft === person.craft; } -export const alertType: AlertType< +export const alertType: RuleType< { outerSpaceCapacity: number; craft: string; op: string }, never, { peopleInSpace: number }, diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 5f6260eb2451c..868b8be7a041c 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -18,7 +18,7 @@ import { actionsConfigMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; import { licenseStateMock } from './lib/license_state.mock'; import { licensingMock } from '../../licensing/server/mocks'; -import { httpServerMock } from '../../../../src/core/server/mocks'; +import { httpServerMock, loggingSystemMock } from '../../../../src/core/server/mocks'; import { auditServiceMock } from '../../security/server/audit/index.mock'; import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock'; @@ -37,6 +37,10 @@ import { actionsAuthorizationMock } from './authorization/actions_authorization. import { trackLegacyRBACExemption } from './lib/track_legacy_rbac_exemption'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from '../../../../src/core/server/elasticsearch/client/mocks'; +import { ConnectorTokenClient } from './builtin_action_types/lib/connector_token_client'; +import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; +import { Logger } from 'kibana/server'; +import { connectorTokenClientMock } from './builtin_action_types/lib/connector_token_client.mock'; jest.mock('../../../../src/core/server/saved_objects/service/lib/utils', () => ({ SavedObjectsUtils: { @@ -71,7 +75,7 @@ const request = httpServerMock.createKibanaRequest(); const auditLogger = auditServiceMock.create().asScoped(request); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); - +const logger = loggingSystemMock.create().get() as jest.Mocked; const mockTaskManager = taskManagerMock.createSetup(); let actionsClient: ActionsClient; @@ -82,6 +86,8 @@ const executor: ExecutorType<{}, {}, {}, void> = async (options) => { return { status: 'ok', actionId: options.actionId }; }; +const connectorTokenClient = connectorTokenClientMock.create(); + beforeEach(() => { jest.resetAllMocks(); mockedLicenseState = licenseStateMock.create(); @@ -107,6 +113,7 @@ beforeEach(() => { authorization: authorization as unknown as ActionsAuthorization, auditLogger, usageCounter: mockUsageCounter, + connectorTokenClient, }); }); @@ -512,6 +519,7 @@ describe('create()', () => { ephemeralExecutionEnqueuer, request, authorization: authorization as unknown as ActionsAuthorization, + connectorTokenClient: connectorTokenClientMock.create(), }); const savedObjectCreateResult = { @@ -627,6 +635,7 @@ describe('get()', () => { }, }, ], + connectorTokenClient: connectorTokenClientMock.create(), }); await actionsClient.get({ id: 'testPreconfigured' }); @@ -683,6 +692,7 @@ describe('get()', () => { }, }, ], + connectorTokenClient: connectorTokenClientMock.create(), }); authorization.ensureAuthorized.mockRejectedValue( @@ -800,6 +810,7 @@ describe('get()', () => { }, }, ], + connectorTokenClient: connectorTokenClientMock.create(), }); const result = await actionsClient.get({ id: 'testPreconfigured' }); @@ -868,6 +879,7 @@ describe('getAll()', () => { }, }, ], + connectorTokenClient: connectorTokenClientMock.create(), }); return actionsClient.getAll(); } @@ -1006,6 +1018,7 @@ describe('getAll()', () => { }, }, ], + connectorTokenClient: connectorTokenClientMock.create(), }); const result = await actionsClient.getAll(); expect(result).toEqual([ @@ -1082,6 +1095,7 @@ describe('getBulk()', () => { }, }, ], + connectorTokenClient: connectorTokenClientMock.create(), }); return actionsClient.getBulk(['1', 'testPreconfigured']); } @@ -1214,6 +1228,7 @@ describe('getBulk()', () => { }, }, ], + connectorTokenClient: connectorTokenClientMock.create(), }); const result = await actionsClient.getBulk(['1', 'testPreconfigured']); expect(result).toEqual([ @@ -1246,6 +1261,7 @@ describe('delete()', () => { test('ensures user is authorised to delete actions', async () => { await actionsClient.delete({ id: '1' }); expect(authorization.ensureAuthorized).toHaveBeenCalledWith('delete'); + expect(connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledTimes(1); }); test('throws when user is not authorised to create the type of action', async () => { @@ -1983,6 +1999,11 @@ describe('isPreconfigured()', () => { }, }, ], + connectorTokenClient: new ConnectorTokenClient({ + unsecuredSavedObjectsClient: savedObjectsClientMock.create(), + encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), + logger, + }), }); expect(actionsClient.isPreconfigured('testPreconfigured')).toEqual(true); @@ -2013,6 +2034,11 @@ describe('isPreconfigured()', () => { }, }, ], + connectorTokenClient: new ConnectorTokenClient({ + unsecuredSavedObjectsClient: savedObjectsClientMock.create(), + encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), + logger, + }), }); expect(actionsClient.isPreconfigured(uuid.v4())).toEqual(false); diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index deaa1a79d1640..7d753a9106a1d 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -29,6 +29,7 @@ import { RawAction, PreConfiguredAction, ActionTypeExecutorResult, + ConnectorTokenClientContract, } from './types'; import { PreconfiguredActionDisabledModificationError } from './lib/errors/preconfigured_action_disabled_modification'; import { ExecuteOptions } from './lib/action_executor'; @@ -77,6 +78,7 @@ interface ConstructorOptions { authorization: ActionsAuthorization; auditLogger?: AuditLogger; usageCounter?: UsageCounter; + connectorTokenClient: ConnectorTokenClientContract; } export interface UpdateOptions { @@ -97,6 +99,7 @@ export class ActionsClient { private readonly ephemeralExecutionEnqueuer: ExecutionEnqueuer; private readonly auditLogger?: AuditLogger; private readonly usageCounter?: UsageCounter; + private readonly connectorTokenClient: ConnectorTokenClientContract; constructor({ actionTypeRegistry, @@ -111,6 +114,7 @@ export class ActionsClient { authorization, auditLogger, usageCounter, + connectorTokenClient, }: ConstructorOptions) { this.actionTypeRegistry = actionTypeRegistry; this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; @@ -124,6 +128,7 @@ export class ActionsClient { this.authorization = authorization; this.auditLogger = auditLogger; this.usageCounter = usageCounter; + this.connectorTokenClient = connectorTokenClient; } /** @@ -475,6 +480,17 @@ export class ActionsClient { }) ); + try { + await this.connectorTokenClient.deleteConnectorTokens({ connectorId: id }); + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.DELETE, + savedObject: { type: 'action', id }, + error, + }) + ); + } return await this.unsecuredSavedObjectsClient.delete('action', id); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 48110e29ff911..456a105a0f081 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -469,6 +469,7 @@ describe('execute()', () => { "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], }, + "connectorId": "some-id", "content": Object { "message": "a message to you @@ -531,6 +532,7 @@ describe('execute()', () => { "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], }, + "connectorId": "some-id", "content": Object { "message": "a message to you @@ -593,6 +595,7 @@ describe('execute()', () => { "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], }, + "connectorId": "some-id", "content": Object { "message": "a message to you diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 624fb2b418f48..ed9509cf98bc6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -241,6 +241,7 @@ async function executor( const config = execOptions.config; const secrets = execOptions.secrets; const params = execOptions.params; + const connectorTokenClient = execOptions.services.connectorTokenClient; const transport: Transport = {}; @@ -283,6 +284,7 @@ async function executor( }); const sendEmailOptions: SendEmailOptions = { + connectorId: actionId, transport, routing: { from: config.from, @@ -301,7 +303,7 @@ async function executor( let result; try { - result = await sendEmail(logger, sendEmailOptions); + result = await sendEmail(logger, sendEmailOptions, connectorTokenClient); } catch (err) { const message = i18n.translate('xpack.actions.builtin.email.errorSendingErrorMessage', { defaultMessage: 'error sending email', diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.mock.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.mock.ts new file mode 100644 index 0000000000000..71d0a2f4466a4 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { ConnectorTokenClient } from './connector_token_client'; + +const createConnectorTokenClientMock = () => { + const mocked: jest.Mocked> = { + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + deleteConnectorTokens: jest.fn(), + }; + return mocked; +}; + +export const connectorTokenClientMock = { + create: createConnectorTokenClientMock, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts new file mode 100644 index 0000000000000..1fa02d172ab36 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts @@ -0,0 +1,359 @@ +/* + * Copyright 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 { loggingSystemMock, savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; +import { ConnectorTokenClient } from './connector_token_client'; +import { Logger } from '../../../../../../src/core/server'; +import { ConnectorToken } from '../../types'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; +jest.mock('../../../../../../src/core/server/saved_objects/service/lib/utils', () => ({ + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, +})); + +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); + +let connectorTokenClient: ConnectorTokenClient; + +beforeEach(() => { + jest.resetAllMocks(); + connectorTokenClient = new ConnectorTokenClient({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }); +}); + +describe('create()', () => { + test('creates connector_token with all given properties', async () => { + const expiresAt = new Date().toISOString(); + const savedObjectCreateResult = { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }, + references: [], + }; + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + const result = await connectorTokenClient.create({ + connectorId: '123', + expiresAtMillis: expiresAt, + token: 'testtokenvalue', + }); + expect(result).toEqual({ + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( + 'testtokenvalue' + ); + }); +}); + +describe('get()', () => { + test('calls unsecuredSavedObjectsClient with parameters', async () => { + const expiresAt = new Date().toISOString(); + const createdAt = new Date().toISOString(); + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + createdAt, + expiresAt, + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + token: 'testtokenvalue', + }, + }); + const result = await connectorTokenClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + expect(result).toEqual({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + }); + }); + + test('return null if there is not tokens for connectorId', async () => { + const expectedResult = { + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + + const result = await connectorTokenClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + expect(result).toEqual({ connectorToken: null, hasErrors: false }); + }); + + test('return null and log the error if unsecuredSavedObjectsClient thows an error', async () => { + unsecuredSavedObjectsClient.find.mockRejectedValueOnce(new Error('Fail')); + + const result = await connectorTokenClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + + expect(logger.error.mock.calls[0]).toMatchObject([ + `Failed to fetch connector_token for connectorId "123" and tokenType: "access_token". Error: Fail`, + ]); + expect(result).toEqual({ connectorToken: null, hasErrors: true }); + }); + + test('return null and log the error if encryptedSavedObjectsClient decrypt method thows an error', async () => { + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); + + const result = await connectorTokenClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + + expect(logger.error.mock.calls[0]).toMatchObject([ + `Failed to decrypt connector_token for connectorId "123" and tokenType: "access_token". Error: Fail`, + ]); + expect(result).toEqual({ connectorToken: null, hasErrors: true }); + }); +}); + +describe('update()', () => { + test('updates the connector token with all given properties', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }, + references: [], + }); + unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ + errors: [], + }); + const result = await connectorTokenClient.update({ + id: '1', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAtMillis: expiresAt, + }); + expect(result).toEqual({ + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( + 'testtokenvalue' + ); + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "connector_token", + "1", + ] + `); + }); + + test('should log error, when failed to update the connector token if there are a conflict errors', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ + errors: [ + { + id: '1', + error: { + error: 'error', + statusCode: 503, + message: 'There is a conflict.', + }, + type: 'conflict', + }, + ], + }); + + const result = await connectorTokenClient.update({ + id: '1', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAtMillis: expiresAt, + }); + expect(result).toEqual(null); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(0); + expect(logger.error.mock.calls[0]).toMatchObject([ + 'Failed to update connector_token for id "1" and tokenType: "access_token". Error: There is a conflict. ', + ]); + }); + + test('throws an error when unsecuredSavedObjectsClient throws', async () => { + const expiresAt = new Date().toISOString(); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ + errors: [], + }); + unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); + await expect( + connectorTokenClient.update({ + id: 'my-action', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAtMillis: expiresAt, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`); + }); +}); + +describe('delete()', () => { + test('calls unsecuredSavedObjectsClient delete for all connector token records by connectorId', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValue(expectedResult); + + const findResult = { + total: 2, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token1', + type: 'connector_token', + attributes: { + connectorId: '1', + tokenType: 'access_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + { + id: 'token2', + type: 'connector_token', + attributes: { + connectorId: '1', + tokenType: 'refresh_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); + const result = await connectorTokenClient.deleteConnectorTokens({ connectorId: '1' }); + expect(JSON.stringify(result)).toEqual(JSON.stringify([Symbol(), Symbol()])); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "connector_token", + "token1", + ] + `); + expect(unsecuredSavedObjectsClient.delete.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + "connector_token", + "token2", + ] + `); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts new file mode 100644 index 0000000000000..b5a91d6e3db69 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts @@ -0,0 +1,252 @@ +/* + * Copyright 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 { omitBy, isUndefined } from 'lodash'; +import { ConnectorToken } from '../../types'; +import { EncryptedSavedObjectsClient } from '../../../../encrypted_saved_objects/server'; +import { + Logger, + SavedObjectsClientContract, + SavedObjectsUtils, +} from '../../../../../../src/core/server'; +import { CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from '../../constants/saved_objects'; + +export const MAX_TOKENS_RETURNED = 1; + +interface ConstructorOptions { + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +interface CreateOptions { + connectorId: string; + token: string; + expiresAtMillis: string; + tokenType?: string; +} + +export interface UpdateOptions { + id: string; + token: string; + expiresAtMillis: string; + tokenType?: string; +} + +export class ConnectorTokenClient { + private readonly logger: Logger; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + + constructor({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }: ConstructorOptions) { + this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.logger = logger; + } + + /** + * Create new token for connector + */ + public async create({ + connectorId, + token, + expiresAtMillis, + tokenType, + }: CreateOptions): Promise { + const id = SavedObjectsUtils.generateId(); + const createTime = Date.now(); + try { + const result = await this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + { + connectorId, + token, + expiresAt: expiresAtMillis, + tokenType: tokenType ?? 'access_token', + createdAt: new Date(createTime).toISOString(), + updatedAt: new Date(createTime).toISOString(), + }, + { id } + ); + + return result.attributes as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update connector token + */ + public async update({ + id, + token, + expiresAtMillis, + tokenType, + }: UpdateOptions): Promise { + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + id + ); + const createTime = Date.now(); + const conflicts = await this.unsecuredSavedObjectsClient.checkConflicts([ + { id, type: 'connector_token' }, + ]); + try { + if (conflicts.errors.length > 0) { + this.logger.error( + `Failed to update connector_token for id "${id}" and tokenType: "${ + tokenType ?? 'access_token' + }". ${conflicts.errors.reduce( + (messages, errorObj) => `Error: ${errorObj.error.message} ${messages}`, + '' + )}` + ); + return null; + } else { + const result = await this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + { + ...attributes, + token, + expiresAt: expiresAtMillis, + tokenType: tokenType ?? 'access_token', + updatedAt: new Date(createTime).toISOString(), + }, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + return result.attributes as ConnectorToken; + } + } catch (err) { + this.logger.error( + `Failed to update connector_token for id "${id}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Get connector token + */ + public async get({ + connectorId, + tokenType, + }: { + connectorId: string; + tokenType?: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: ConnectorToken | null; + }> { + const connectorTokensResult = []; + const tokenTypeFilter = tokenType + ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` + : ''; + + try { + connectorTokensResult.push( + ...( + await this.unsecuredSavedObjectsClient.find({ + perPage: MAX_TOKENS_RETURNED, + type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, + sortField: 'updatedAt', + sortOrder: 'desc', + }) + ).saved_objects + ); + } catch (err) { + this.logger.error( + `Failed to fetch connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + + if (connectorTokensResult.length === 0) { + return { hasErrors: false, connectorToken: null }; + } + + try { + const { + attributes: { token }, + } = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + connectorTokensResult[0].id + ); + + return { + hasErrors: false, + connectorToken: { + id: connectorTokensResult[0].id, + ...connectorTokensResult[0].attributes, + token, + }, + }; + } catch (err) { + this.logger.error( + `Failed to decrypt connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + } + + /** + * Delete all connector tokens + */ + public async deleteConnectorTokens({ + connectorId, + tokenType, + }: { + connectorId: string; + tokenType?: string; + }) { + const tokenTypeFilter = tokenType + ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` + : ''; + try { + const result = await this.unsecuredSavedObjectsClient.find({ + type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, + }); + return Promise.all( + result.saved_objects.map( + async (obj) => + await this.unsecuredSavedObjectsClient.delete(CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, obj.id) + ) + ); + } catch (err) { + this.logger.error( + `Failed to delete connector_token records for connectorId "${connectorId}". Error: ${err.message}` + ); + throw err; + } + } +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 3efc33c339de5..c3fc1c8128ffc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -18,18 +18,29 @@ jest.mock('./request_oauth_client_credentials_token', () => ({ import { Logger } from '../../../../../../src/core/server'; import { sendEmail } from './send_email'; -import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { loggingSystemMock, savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; import { actionsConfigMock } from '../../actions_config.mock'; import { CustomHostSettings } from '../../config'; import { sendEmailGraphApi } from './send_email_graph_api'; import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; +import { ConnectorTokenClient } from './connector_token_client'; +import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; +import { connectorTokenClientMock } from './connector_token_client.mock'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; const sendMailMock = jest.fn(); const mockLogger = loggingSystemMock.create().get() as jest.Mocked; +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); + +const connectorTokenClient = new ConnectorTokenClient({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger: mockLogger, +}); describe('send_email module', () => { beforeEach(() => { @@ -40,7 +51,7 @@ describe('send_email module', () => { test('handles authenticated email using service', async () => { const sendEmailOptions = getSendEmailOptions({ transport: { service: 'other' } }); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -90,11 +101,22 @@ describe('send_email module', () => { }, }); requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - status: 200, - data: { - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, + tokenType: 'Bearer', + accessToken: 'dfjsdfgdjhfgsjdf', + expiresIn: 123, + }); + const date = new Date(); + date.setDate(date.getDate() + 5); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + connectorId: '123', + expiresAt: date.toISOString(), + tokenType: 'access_token', + token: '11111111', }, }); @@ -102,7 +124,13 @@ describe('send_email module', () => { status: 202, }); - await sendEmail(mockLogger, sendEmailOptions); + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 0, + saved_objects: [], + per_page: 500, + page: 1, + }); + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(requestOAuthClientCredentialsTokenMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ "https://login.microsoftonline.com/undefined/oauth2/v2.0/token", @@ -153,7 +181,162 @@ describe('send_email module', () => { Object { "graphApiUrl": undefined, "headers": Object { - "Authorization": "undefined undefined", + "Authorization": "Bearer dfjsdfgdjhfgsjdf", + "Content-Type": "application/json", + }, + "messageHTML": "

a message

+ ", + "options": Object { + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], + "getMicrosoftGraphApiUrl": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getProxySettings": [MockFunction], + "getResponseSettings": [MockFunction], + "getSSLSettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "connectorId": "1", + "content": Object { + "message": "a message", + "subject": "a subject", + }, + "hasAuth": true, + "routing": Object { + "bcc": Array [], + "cc": Array [ + "bob@example.com", + "robert@example.com", + ], + "from": "fred@example.com", + "to": Array [ + "jim@example.com", + ], + }, + "transport": Object { + "clientId": "123456", + "clientSecret": "sdfhkdsjhfksdjfh", + "password": "changeme", + "service": "exchange_server", + "user": "elastic", + }, + }, + }, + Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], + "getMicrosoftGraphApiUrl": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getProxySettings": [MockFunction], + "getResponseSettings": [MockFunction], + "getSSLSettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isUriAllowed": [MockFunction], + }, + ] + `); + + expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); + }); + + test('uses existing "access_token" from "connector_token" SO for authentication for email using "exchange_server" service', async () => { + const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; + const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; + const sendEmailOptions = getSendEmailOptions({ + transport: { + service: 'exchange_server', + clientId: '123456', + clientSecret: 'sdfhkdsjhfksdjfh', + }, + }); + requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ + tokenType: 'Bearer', + accessToken: 'dfjsdfgdjhfgsjdf', + expiresIn: 123, + }); + + sendEmailGraphApiMock.mockReturnValue({ + status: 202, + }); + const date = new Date(); + date.setDate(date.getDate() + 5); + + unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ + errors: [], + }); + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [ + { + id: '1', + score: 1, + type: 'connector_token', + references: [], + attributes: { + connectorId: '123', + expiresAt: date.toISOString(), + tokenType: 'access_token', + }, + }, + ], + per_page: 500, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + token: '11111111', + }, + }); + + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(0); + + expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "graphApiUrl": undefined, + "headers": Object { + "Authorization": "11111111", "Content-Type": "application/json", }, "messageHTML": "

a message

@@ -182,6 +365,477 @@ describe('send_email module', () => { "isHostnameAllowed": [MockFunction], "isUriAllowed": [MockFunction], }, + "connectorId": "1", + "content": Object { + "message": "a message", + "subject": "a subject", + }, + "hasAuth": true, + "routing": Object { + "bcc": Array [], + "cc": Array [ + "bob@example.com", + "robert@example.com", + ], + "from": "fred@example.com", + "to": Array [ + "jim@example.com", + ], + }, + "transport": Object { + "clientId": "123456", + "clientSecret": "sdfhkdsjhfksdjfh", + "password": "changeme", + "service": "exchange_server", + "user": "elastic", + }, + }, + }, + Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], + "getMicrosoftGraphApiUrl": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getProxySettings": [MockFunction], + "getResponseSettings": [MockFunction], + "getSSLSettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isUriAllowed": [MockFunction], + }, + ] + `); + + expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(0); + }); + + test('request the new token and update existing "access_token" when it is expired for "exchange_server" email service', async () => { + const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; + const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; + const sendEmailOptions = getSendEmailOptions({ + transport: { + service: 'exchange_server', + clientId: '123456', + clientSecret: 'sdfhkdsjhfksdjfh', + }, + }); + requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ + tokenType: 'Bearer', + accessToken: 'dfjsdfgdjhfgsjdf', + expiresIn: 123, + }); + + sendEmailGraphApiMock.mockReturnValue({ + status: 202, + }); + const date = new Date(); + date.setDate(date.getDate() - 5); + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [ + { + id: '1', + score: 1, + type: 'connector_token', + references: [], + attributes: { + connectorId: '123', + expiresAt: date.toISOString(), + tokenType: 'access_token', + }, + }, + ], + per_page: 500, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + token: '11111111', + }, + }); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + connectorId: '123', + expiresAt: date.toISOString(), + tokenType: 'access_token', + token: '11111111', + }, + }); + unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ + errors: [], + }); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + connectorId: '123', + expiresAt: date.toISOString(), + tokenType: 'access_token', + token: '11111111', + }, + }); + + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); + + expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "graphApiUrl": undefined, + "headers": Object { + "Authorization": "Bearer dfjsdfgdjhfgsjdf", + "Content-Type": "application/json", + }, + "messageHTML": "

a message

+ ", + "options": Object { + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], + "getMicrosoftGraphApiUrl": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getProxySettings": [MockFunction], + "getResponseSettings": [MockFunction], + "getSSLSettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "connectorId": "1", + "content": Object { + "message": "a message", + "subject": "a subject", + }, + "hasAuth": true, + "routing": Object { + "bcc": Array [], + "cc": Array [ + "bob@example.com", + "robert@example.com", + ], + "from": "fred@example.com", + "to": Array [ + "jim@example.com", + ], + }, + "transport": Object { + "clientId": "123456", + "clientSecret": "sdfhkdsjhfksdjfh", + "password": "changeme", + "service": "exchange_server", + "user": "elastic", + }, + }, + }, + Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], + "getMicrosoftGraphApiUrl": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getProxySettings": [MockFunction], + "getResponseSettings": [MockFunction], + "getSSLSettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isUriAllowed": [MockFunction], + }, + ] + `); + + expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); + }); + + test('sending email for "exchange_server" wont fail if connectorTokenClient throw the errors, just log warning message', async () => { + const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; + const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; + const sendEmailOptions = getSendEmailOptions({ + transport: { + service: 'exchange_server', + clientId: '123456', + clientSecret: 'sdfhkdsjhfksdjfh', + }, + }); + requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ + tokenType: 'Bearer', + accessToken: 'dfjsdfgdjhfgsjdf', + expiresIn: 123, + }); + + sendEmailGraphApiMock.mockReturnValue({ + status: 202, + }); + const date = new Date(); + date.setDate(date.getDate() + 5); + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 0, + saved_objects: [], + per_page: 500, + page: 1, + }); + unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); + + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); + expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); + expect(mockLogger.warn.mock.calls[0]).toMatchObject([ + `Not able to update connector token for connectorId: 1 due to error: Fail`, + ]); + + expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "graphApiUrl": undefined, + "headers": Object { + "Authorization": "Bearer dfjsdfgdjhfgsjdf", + "Content-Type": "application/json", + }, + "messageHTML": "

a message

+ ", + "options": Object { + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], + "getMicrosoftGraphApiUrl": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getProxySettings": [MockFunction], + "getResponseSettings": [MockFunction], + "getSSLSettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "connectorId": "1", + "content": Object { + "message": "a message", + "subject": "a subject", + }, + "hasAuth": true, + "routing": Object { + "bcc": Array [], + "cc": Array [ + "bob@example.com", + "robert@example.com", + ], + "from": "fred@example.com", + "to": Array [ + "jim@example.com", + ], + }, + "transport": Object { + "clientId": "123456", + "clientSecret": "sdfhkdsjhfksdjfh", + "password": "changeme", + "service": "exchange_server", + "user": "elastic", + }, + }, + }, + Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction] { + "calls": Array [ + Array [ + "Failed to create connector_token for connectorId \\"1\\" and tokenType: \\"access_token\\". Error: Fail", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction] { + "calls": Array [ + Array [ + "Not able to update connector token for connectorId: 1 due to error: Fail", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], + "getMicrosoftGraphApiUrl": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getProxySettings": [MockFunction], + "getResponseSettings": [MockFunction], + "getSSLSettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isUriAllowed": [MockFunction], + }, + ] + `); + }); + + test('delete duplication tokens if connectorTokenClient get method has the errors, like decription error', async () => { + const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; + const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; + const sendEmailOptions = getSendEmailOptions({ + transport: { + service: 'exchange_server', + clientId: '123456', + clientSecret: 'sdfhkdsjhfksdjfh', + }, + }); + requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ + tokenType: 'Bearer', + accessToken: 'dfjsdfgdjhfgsjdf', + expiresIn: 123, + }); + + sendEmailGraphApiMock.mockReturnValue({ + status: 202, + }); + const date = new Date(); + date.setDate(date.getDate() + 5); + + const connectorTokenClientM = connectorTokenClientMock.create(); + connectorTokenClientM.get.mockResolvedValueOnce({ + hasErrors: true, + connectorToken: null, + }); + + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClientM); + expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); + expect(connectorTokenClientM.deleteConnectorTokens.mock.calls.length).toBe(1); + + expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "graphApiUrl": undefined, + "headers": Object { + "Authorization": "Bearer dfjsdfgdjhfgsjdf", + "Content-Type": "application/json", + }, + "messageHTML": "

a message

+ ", + "options": Object { + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], + "getMicrosoftGraphApiUrl": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getProxySettings": [MockFunction], + "getResponseSettings": [MockFunction], + "getSSLSettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "connectorId": "1", "content": Object { "message": "a message", "subject": "a subject", @@ -264,7 +918,7 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -314,7 +968,7 @@ describe('send_email module', () => { delete sendEmailOptions.transport.user; // @ts-expect-error delete sendEmailOptions.transport.password; - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -364,7 +1018,7 @@ describe('send_email module', () => { // @ts-expect-error delete sendEmailOptions.transport.password; - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -405,7 +1059,9 @@ describe('send_email module', () => { sendMailMock.mockReset(); sendMailMock.mockRejectedValue(new Error('wops')); - await expect(sendEmail(mockLogger, sendEmailOptions)).rejects.toThrow('wops'); + await expect(sendEmail(mockLogger, sendEmailOptions, connectorTokenClient)).rejects.toThrow( + 'wops' + ); }); test('it bypasses with proxyBypassHosts when expected', async () => { @@ -426,7 +1082,7 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -460,7 +1116,7 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -496,7 +1152,7 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -530,7 +1186,7 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -567,7 +1223,7 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); // note in the object below, the rejectUnauthenticated got set to false, @@ -610,7 +1266,7 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); // in this case, rejectUnauthorized is true, as the custom host settings @@ -657,7 +1313,7 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions); + const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -711,6 +1367,7 @@ function getSendEmailOptions( }, hasAuth: true, configurationUtilities, + connectorId: '1', }; } @@ -745,5 +1402,6 @@ function getSendEmailOptionsNoAuth( }, hasAuth: false, configurationUtilities, + connectorId: '2', }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index 53c70fddc5a09..378edc174e790 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -15,7 +15,7 @@ import { CustomHostSettings } from '../../config'; import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; import { sendEmailGraphApi } from './send_email_graph_api'; import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; -import { ProxySettings } from '../../types'; +import { ConnectorTokenClientContract, ProxySettings } from '../../types'; import { AdditionalEmailServices } from '../../../common'; // an email "service" which doesn't actually send, just returns what it would send @@ -25,6 +25,7 @@ export const GRAPH_API_OAUTH_SCOPE = 'https://graph.microsoft.com/.default'; export const EXCHANGE_ONLINE_SERVER_HOST = 'https://login.microsoftonline.com'; export interface SendEmailOptions { + connectorId: string; transport: Transport; routing: Routing; content: Content; @@ -59,13 +60,17 @@ export interface Content { message: string; } -export async function sendEmail(logger: Logger, options: SendEmailOptions): Promise { +export async function sendEmail( + logger: Logger, + options: SendEmailOptions, + connectorTokenClient: ConnectorTokenClientContract +): Promise { const { transport, content } = options; const { message } = content; const messageHTML = htmlFromMarkdown(logger, message); if (transport.service === AdditionalEmailServices.EXCHANGE) { - return await sendEmailWithExchange(logger, options, messageHTML); + return await sendEmailWithExchange(logger, options, messageHTML, connectorTokenClient); } else { return await sendEmailWithNodemailer(logger, options, messageHTML); } @@ -75,25 +80,67 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom async function sendEmailWithExchange( logger: Logger, options: SendEmailOptions, - messageHTML: string + messageHTML: string, + connectorTokenClient: ConnectorTokenClientContract ): Promise { - const { transport, configurationUtilities } = options; + const { transport, configurationUtilities, connectorId } = options; const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport; - // request access token for microsoft exchange online server with Graph API scope - const tokenResult = await requestOAuthClientCredentialsToken( - oauthTokenUrl ?? `${EXCHANGE_ONLINE_SERVER_HOST}/${tenantId}/oauth2/v2.0/token`, - logger, - { - scope: GRAPH_API_OAUTH_SCOPE, - clientId, - clientSecret, - }, - configurationUtilities - ); + let accessToken: string; + + const { connectorToken, hasErrors } = await connectorTokenClient.get({ connectorId }); + if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + // request new access token for microsoft exchange online server with Graph API scope + const tokenResult = await requestOAuthClientCredentialsToken( + oauthTokenUrl ?? `${EXCHANGE_ONLINE_SERVER_HOST}/${tenantId}/oauth2/v2.0/token`, + logger, + { + scope: GRAPH_API_OAUTH_SCOPE, + clientId, + clientSecret, + }, + configurationUtilities + ); + accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + + // try to update connector_token SO + try { + if (connectorToken === null) { + if (hasErrors) { + // delete existing access tokens + await connectorTokenClient.deleteConnectorTokens({ + connectorId, + tokenType: 'access_token', + }); + } + await connectorTokenClient.create({ + connectorId, + token: accessToken, + // convert MS Exchange expiresIn from seconds to milliseconds + expiresAtMillis: new Date(Date.now() + tokenResult.expiresIn * 1000).toISOString(), + tokenType: 'access_token', + }); + } else { + await connectorTokenClient.update({ + id: connectorToken.id!.toString(), + token: accessToken, + // convert MS Exchange expiresIn from seconds to milliseconds + expiresAtMillis: new Date(Date.now() + tokenResult.expiresIn * 1000).toISOString(), + tokenType: 'access_token', + }); + } + } catch (err) { + logger.warn( + `Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}` + ); + } + } else { + // use existing valid token + accessToken = connectorToken.token; + } const headers = { 'Content-Type': 'application/json', - Authorization: `${tokenResult.tokenType} ${tokenResult.accessToken}`, + Authorization: accessToken, }; return await sendEmailGraphApi( diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.test.ts index a50dee8f1cbc2..63cd3523b0026 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.test.ts @@ -350,5 +350,6 @@ function getSendEmailOptions( }, hasAuth: true, configurationUtilities, + connectorId: '1', }; } diff --git a/x-pack/plugins/actions/server/constants/saved_objects.ts b/x-pack/plugins/actions/server/constants/saved_objects.ts index aa79d56fac874..9064b6e71b84f 100644 --- a/x-pack/plugins/actions/server/constants/saved_objects.ts +++ b/x-pack/plugins/actions/server/constants/saved_objects.ts @@ -8,3 +8,4 @@ export const ACTION_SAVED_OBJECT_TYPE = 'action'; export const ALERT_SAVED_OBJECT_TYPE = 'alert'; export const ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE = 'action_task_params'; +export const CONNECTOR_TOKEN_SAVED_OBJECT_TYPE = 'connector_token'; diff --git a/x-pack/plugins/actions/server/feature.ts b/x-pack/plugins/actions/server/feature.ts index 42227d3b885ad..c6265a17b122e 100644 --- a/x-pack/plugins/actions/server/feature.ts +++ b/x-pack/plugins/actions/server/feature.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, } from './constants/saved_objects'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -31,7 +32,11 @@ export const ACTIONS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, savedObject: { - all: [ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE], + all: [ + ACTION_SAVED_OBJECT_TYPE, + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + ], read: [], }, ui: ['show', 'execute', 'save', 'delete'], @@ -45,7 +50,7 @@ export const ACTIONS_FEATURE = { }, savedObject: { // action execution requires 'read' over `actions`, but 'all' over `action_task_params` - all: [ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE], + all: [ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE], read: [ACTION_SAVED_OBJECT_TYPE], }, ui: ['show', 'execute'], diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 4afdd01777f4f..bdfdab5124e29 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -10,11 +10,16 @@ import { PluginSetupContract, PluginStartContract, renderActionParameterTemplate import { Services } from './types'; import { elasticsearchServiceMock, + loggingSystemMock, savedObjectsClientMock, } from '../../../../src/core/server/mocks'; import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; +import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; +import { ConnectorTokenClient } from './builtin_action_types/lib/connector_token_client'; +import { Logger } from '../../../../src/core/server'; export { actionsAuthorizationMock }; export { actionsClientMock }; +const logger = loggingSystemMock.create().get() as jest.Mocked; const createSetupMock = () => { const mock: jest.Mocked = { @@ -56,6 +61,11 @@ const createServicesMock = () => { > = { savedObjectsClient: savedObjectsClientMock.create(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, + connectorTokenClient: new ConnectorTokenClient({ + unsecuredSavedObjectsClient: savedObjectsClientMock.create(), + encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), + logger, + }), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 08ea99df67c8e..2d854df6a5853 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -60,6 +60,14 @@ describe('Actions Plugin', () => { usageCollection: usageCollectionPluginMock.createSetupContract(), features: featuresPluginMock.createSetup(), }; + coreSetup.getStartServices.mockResolvedValue([ + coreMock.createStart(), + { + ...pluginsSetup, + encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), + }, + {}, + ]); }); it('should log warning when Encrypted Saved Objects plugin is missing encryption key', async () => { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index bbf00572935fa..985bbaf688e12 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -21,6 +21,7 @@ import { } from '../../../../src/core/server'; import { + EncryptedSavedObjectsClient, EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart, } from '../../encrypted_saved_objects/server'; @@ -70,6 +71,7 @@ import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, ALERT_SAVED_OBJECT_TYPE, + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, } from './constants/saved_objects'; import { setupSavedObjects } from './saved_objects'; import { ACTIONS_FEATURE } from './feature'; @@ -85,6 +87,7 @@ import { getAlertHistoryEsIndex } from './preconfigured_connectors/alert_history import { createAlertHistoryIndexTemplate } from './preconfigured_connectors/alert_history_es_index/create_alert_history_index_template'; import { ACTIONS_FEATURE_ID, AlertHistoryEsIndexConnectorId } from '../common'; import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER } from './constants/event_log'; +import { ConnectorTokenClient } from './builtin_action_types/lib/connector_token_client'; export interface PluginSetupContract { registerType< @@ -144,6 +147,7 @@ const includedHiddenTypes = [ ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, ALERT_SAVED_OBJECT_TYPE, + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, ]; export class ActionsPlugin implements Plugin { @@ -376,6 +380,11 @@ export class ActionsPlugin implements Plugin this.getUnsecuredSavedObjectsClient(core.savedObjects, request) ), encryptedSavedObjectsClient, actionTypeRegistry: actionTypeRegistry!, @@ -484,12 +495,21 @@ export class ActionsPlugin implements Plugin SavedObjectsClientContract, - elasticsearch: ElasticsearchServiceStart + elasticsearch: ElasticsearchServiceStart, + encryptedSavedObjectsClient: EncryptedSavedObjectsClient, + unsecuredSavedObjectsClient: (request: KibanaRequest) => SavedObjectsClientContract ): (request: KibanaRequest) => Services { - return (request) => ({ - savedObjectsClient: getScopedClient(request), - scopedClusterClient: elasticsearch.client.asScoped(request).asCurrentUser, - }); + return (request) => { + return { + savedObjectsClient: getScopedClient(request), + scopedClusterClient: elasticsearch.client.asScoped(request).asCurrentUser, + connectorTokenClient: new ConnectorTokenClient({ + unsecuredSavedObjectsClient: unsecuredSavedObjectsClient(request), + encryptedSavedObjectsClient, + logger: this.logger, + }), + }; + }; } private createRouteHandlerContext = ( @@ -504,10 +524,12 @@ export class ActionsPlugin implements Plugin { if (isESOCanEncrypt !== true) { @@ -515,11 +537,12 @@ export class ActionsPlugin implements Plugin string | undefine export type ActionTypeConfig = Record; export type ActionTypeSecrets = Record; export type ActionTypeParams = Record; +export type ConnectorTokenClientContract = PublicMethodsOf; export interface Services { savedObjectsClient: SavedObjectsClientContract; scopedClusterClient: ElasticsearchClient; + connectorTokenClient: ConnectorTokenClient; } export interface ActionsApiRequestHandlerContext { @@ -173,3 +176,12 @@ export interface ResponseSettings { export interface SSLSettings { verificationMode?: 'none' | 'certificate' | 'full'; } + +export interface ConnectorToken extends SavedObjectAttributes { + connectorId: string; + tokenType: string; + token: string; + expiresAt: string; + createdAt: string; + updatedAt?: string; +} diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 3646dbddb347d..fcb31977806c9 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -230,7 +230,7 @@ interface MyRuleTypeAlertContext extends AlertInstanceContext { type MyRuleTypeActionGroups = 'default' | 'warning'; -const myRuleType: AlertType< +const myRuleType: RuleType< MyRuleTypeParams, MyRuleTypeExtractedParams, MyRuleTypeState, diff --git a/x-pack/plugins/alerting/common/builtin_action_groups.ts b/x-pack/plugins/alerting/common/builtin_action_groups.ts index ada5f08e85d92..ed1d0746f0f26 100644 --- a/x-pack/plugins/alerting/common/builtin_action_groups.ts +++ b/x-pack/plugins/alerting/common/builtin_action_groups.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ActionGroup } from './alert_type'; +import { ActionGroup } from './rule_type'; export type DefaultActionGroupId = 'default'; diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 1c7525a065760..7296766e6955e 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -11,9 +11,9 @@ import { AlertsHealth } from './alert'; export * from './alert'; -export * from './alert_type'; +export * from './rule_type'; export * from './alert_instance'; -export * from './alert_task_instance'; +export * from './rule_task_instance'; export * from './alert_navigation'; export * from './alert_summary'; export * from './builtin_action_groups'; diff --git a/x-pack/plugins/alerting/common/alert_task_instance.ts b/x-pack/plugins/alerting/common/rule_task_instance.ts similarity index 74% rename from x-pack/plugins/alerting/common/alert_task_instance.ts rename to x-pack/plugins/alerting/common/rule_task_instance.ts index fc8b27495e521..fdd308a6395a1 100644 --- a/x-pack/plugins/alerting/common/alert_task_instance.ts +++ b/x-pack/plugins/alerting/common/rule_task_instance.ts @@ -9,15 +9,15 @@ import * as t from 'io-ts'; import { rawAlertInstance } from './alert_instance'; import { DateFromString } from './date_from_string'; -export const alertStateSchema = t.partial({ +export const ruleStateSchema = t.partial({ alertTypeState: t.record(t.string, t.unknown), alertInstances: t.record(t.string, rawAlertInstance), previousStartedAt: t.union([t.null, DateFromString]), }); -export type AlertTaskState = t.TypeOf; +export type RuleTaskState = t.TypeOf; -export const alertParamsSchema = t.intersection([ +export const ruleParamsSchema = t.intersection([ t.type({ alertId: t.string, }), @@ -25,4 +25,4 @@ export const alertParamsSchema = t.intersection([ spaceId: t.string, }), ]); -export type AlertTaskParams = t.TypeOf; +export type RuleTaskParams = t.TypeOf; diff --git a/x-pack/plugins/alerting/common/alert_type.ts b/x-pack/plugins/alerting/common/rule_type.ts similarity index 97% rename from x-pack/plugins/alerting/common/alert_type.ts rename to x-pack/plugins/alerting/common/rule_type.ts index 1b0ac28c9fa74..f1907917d01fd 100644 --- a/x-pack/plugins/alerting/common/alert_type.ts +++ b/x-pack/plugins/alerting/common/rule_type.ts @@ -8,7 +8,7 @@ import { LicenseType } from '../../licensing/common/types'; import { RecoveredActionGroupId, DefaultActionGroupId } from './builtin_action_groups'; -export interface AlertType< +export interface RuleType< ActionGroupIds extends Exclude = DefaultActionGroupId, RecoveryActionGroupId extends string = RecoveredActionGroupId > { diff --git a/x-pack/plugins/alerting/public/alert_api.test.ts b/x-pack/plugins/alerting/public/alert_api.test.ts index dd2f7d167c1c3..cabccbacb42df 100644 --- a/x-pack/plugins/alerting/public/alert_api.test.ts +++ b/x-pack/plugins/alerting/public/alert_api.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AlertType, RecoveredActionGroup } from '../common'; +import { RuleType, RecoveredActionGroup } from '../common'; import { httpServiceMock } from '../../../../src/core/public/mocks'; import { loadAlert, loadAlertType, loadAlertTypes } from './alert_api'; import uuid from 'uuid'; @@ -16,7 +16,7 @@ beforeEach(() => jest.resetAllMocks()); describe('loadAlertTypes', () => { test('should call get alert types API', async () => { - const resolvedValue: AlertType[] = [ + const resolvedValue: RuleType[] = [ { id: 'test', name: 'Test', @@ -43,7 +43,7 @@ describe('loadAlertTypes', () => { describe('loadAlertType', () => { test('should call get alert types API', async () => { - const alertType: AlertType = { + const alertType: RuleType = { id: 'test', name: 'Test', actionVariables: ['var1'], @@ -66,7 +66,7 @@ describe('loadAlertType', () => { }); test('should find the required alertType', async () => { - const alertType: AlertType = { + const alertType: RuleType = { id: 'test-another', name: 'Test Another', actionVariables: [], diff --git a/x-pack/plugins/alerting/public/alert_api.ts b/x-pack/plugins/alerting/public/alert_api.ts index f3faa65a4b384..e323ce9dc41e5 100644 --- a/x-pack/plugins/alerting/public/alert_api.ts +++ b/x-pack/plugins/alerting/public/alert_api.ts @@ -7,9 +7,9 @@ import { HttpSetup } from 'kibana/public'; import { LEGACY_BASE_ALERT_API_PATH } from '../common'; -import type { Alert, AlertType } from '../common'; +import type { Alert, RuleType } from '../common'; -export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { +export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { return await http.get(`${LEGACY_BASE_ALERT_API_PATH}/list_alert_types`); } @@ -18,11 +18,11 @@ export async function loadAlertType({ id, }: { http: HttpSetup; - id: AlertType['id']; -}): Promise { + id: RuleType['id']; +}): Promise { const alertTypes = (await http.get( `${LEGACY_BASE_ALERT_API_PATH}/list_alert_types` - )) as AlertType[]; + )) as RuleType[]; return alertTypes.find((type) => type.id === id); } diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts index af009217ed99b..f28f3dd5f7b78 100644 --- a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts @@ -6,12 +6,12 @@ */ import { AlertNavigationRegistry } from './alert_navigation_registry'; -import { AlertType, RecoveredActionGroup, SanitizedAlert } from '../../common'; +import { RuleType, RecoveredActionGroup, SanitizedAlert } from '../../common'; import uuid from 'uuid'; beforeEach(() => jest.resetAllMocks()); -const mockAlertType = (id: string): AlertType => ({ +const mockAlertType = (id: string): RuleType => ({ id, name: id, actionGroups: [], diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts index 0c7bf052fef4c..4bc65c6baf4ef 100644 --- a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { AlertType } from '../../common'; +import { RuleType } from '../../common'; import { AlertNavigationHandler } from './types'; const DEFAULT_HANDLER = Symbol('*'); @@ -14,7 +14,7 @@ export class AlertNavigationRegistry { private readonly alertNavigations: Map> = new Map(); - public has(consumer: string, alertType: AlertType) { + public has(consumer: string, alertType: RuleType) { return this.hasTypedHandler(consumer, alertType.id) || this.hasDefaultHandler(consumer); } @@ -70,7 +70,7 @@ export class AlertNavigationRegistry { consumerNavigations.set(ruleTypeId, handler); } - public get(consumer: string, alertType: AlertType): AlertNavigationHandler { + public get(consumer: string, alertType: RuleType): AlertNavigationHandler { if (this.has(consumer, alertType)) { const consumerHandlers = this.alertNavigations.get(consumer)!; return (consumerHandlers.get(alertType.id) ?? consumerHandlers.get(DEFAULT_HANDLER))!; diff --git a/x-pack/plugins/alerting/server/health/get_health.ts b/x-pack/plugins/alerting/server/health/get_health.ts index 6966c9b75ca43..09a5922576192 100644 --- a/x-pack/plugins/alerting/server/health/get_health.ts +++ b/x-pack/plugins/alerting/server/health/get_health.ts @@ -6,7 +6,7 @@ */ import { ISavedObjectsRepository, SavedObjectsServiceStart } from 'src/core/server'; -import { AlertsHealth, HealthStatus, RawAlert, AlertExecutionStatusErrorReasons } from '../types'; +import { AlertsHealth, HealthStatus, RawRule, AlertExecutionStatusErrorReasons } from '../types'; export const getHealth = async ( internalSavedObjectsRepository: ISavedObjectsRepository @@ -26,7 +26,7 @@ export const getHealth = async ( }, }; - const { saved_objects: decryptErrorData } = await internalSavedObjectsRepository.find({ + const { saved_objects: decryptErrorData } = await internalSavedObjectsRepository.find({ filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Decrypt}`, fields: ['executionStatus'], type: 'alert', @@ -44,7 +44,7 @@ export const getHealth = async ( }; } - const { saved_objects: executeErrorData } = await internalSavedObjectsRepository.find({ + const { saved_objects: executeErrorData } = await internalSavedObjectsRepository.find({ filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Execute}`, fields: ['executionStatus'], type: 'alert', @@ -62,7 +62,7 @@ export const getHealth = async ( }; } - const { saved_objects: readErrorData } = await internalSavedObjectsRepository.find({ + const { saved_objects: readErrorData } = await internalSavedObjectsRepository.find({ filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Read}`, fields: ['executionStatus'], type: 'alert', @@ -80,7 +80,7 @@ export const getHealth = async ( }; } - const { saved_objects: noErrorData } = await internalSavedObjectsRepository.find({ + const { saved_objects: noErrorData } = await internalSavedObjectsRepository.find({ filter: 'not alert.attributes.executionStatus.status:error', fields: ['executionStatus'], type: 'alert', diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 8ed91cc821412..90bda8b1e09d4 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -14,7 +14,7 @@ import { AlertsConfigType } from './types'; export type RulesClient = PublicMethodsOf; export type { - AlertType, + RuleType, ActionGroup, ActionGroupIdsOf, AlertingPlugin, diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts index 0731886bcaeb0..a7a00034e7064 100644 --- a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts +++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts @@ -6,11 +6,11 @@ */ import { createAlertEventLogRecordObject } from './create_alert_event_log_record_object'; -import { UntypedNormalizedAlertType } from '../rule_type_registry'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { RecoveredActionGroup } from '../types'; describe('createAlertEventLogRecordObject', () => { - const ruleType: jest.Mocked = { + const ruleType: jest.Mocked = { id: 'test', name: 'My test alert', actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts index 12300211cb0bb..e06b5bf893bac 100644 --- a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts +++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts @@ -7,13 +7,13 @@ import { AlertInstanceState } from '../types'; import { IEvent } from '../../../event_log/server'; -import { UntypedNormalizedAlertType } from '../rule_type_registry'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; export type Event = Exclude; interface CreateAlertEventLogRecordParams { ruleId: string; - ruleType: UntypedNormalizedAlertType; + ruleType: UntypedNormalizedRuleType; action: string; ruleName?: string; instanceId?: string; diff --git a/x-pack/plugins/alerting/server/lib/get_alert_type_feature_usage_name.ts b/x-pack/plugins/alerting/server/lib/get_rule_type_feature_usage_name.ts similarity index 70% rename from x-pack/plugins/alerting/server/lib/get_alert_type_feature_usage_name.ts rename to x-pack/plugins/alerting/server/lib/get_rule_type_feature_usage_name.ts index 71879e1dca8ac..85f3e9bfb50b4 100644 --- a/x-pack/plugins/alerting/server/lib/get_alert_type_feature_usage_name.ts +++ b/x-pack/plugins/alerting/server/lib/get_rule_type_feature_usage_name.ts @@ -5,6 +5,6 @@ * 2.0. */ -export function getAlertTypeFeatureUsageName(alertTypeName: string) { - return `Alert: ${alertTypeName}`; +export function getRuleTypeFeatureUsageName(ruleTypeName: string) { + return `Rule: ${ruleTypeName}`; } diff --git a/x-pack/plugins/alerting/server/lib/index.ts b/x-pack/plugins/alerting/server/lib/index.ts index 24f2513e1c650..29526f17268f2 100644 --- a/x-pack/plugins/alerting/server/lib/index.ts +++ b/x-pack/plugins/alerting/server/lib/index.ts @@ -8,7 +8,7 @@ export { parseDuration, validateDurationSchema } from '../../common/parse_duration'; export type { ILicenseState } from './license_state'; export { LicenseState } from './license_state'; -export { validateAlertTypeParams } from './validate_alert_type_params'; +export { validateRuleTypeParams } from './validate_rule_type_params'; export { getAlertNotifyWhenType } from './get_alert_notify_when_type'; export { verifyApiAccess } from './license_api_access'; export { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason'; @@ -21,6 +21,6 @@ export { AlertTypeDisabledError, isErrorThatHandlesItsOwnResponse } from './erro export { executionStatusFromState, executionStatusFromError, - alertExecutionStatusToRaw, - alertExecutionStatusFromRaw, -} from './alert_execution_status'; + ruleExecutionStatusToRaw, + ruleExecutionStatusFromRaw, +} from './rule_execution_status'; diff --git a/x-pack/plugins/alerting/server/lib/license_state.mock.ts b/x-pack/plugins/alerting/server/lib/license_state.mock.ts index 1521a1cf25da9..0f1b618eea761 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.mock.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.mock.ts @@ -11,8 +11,8 @@ export const createLicenseStateMock = () => { const licenseState: jest.Mocked = { clean: jest.fn(), getLicenseInformation: jest.fn(), - ensureLicenseForAlertType: jest.fn(), - getLicenseCheckForAlertType: jest.fn().mockResolvedValue({ + ensureLicenseForRuleType: jest.fn(), + getLicenseCheckForRuleType: jest.fn().mockResolvedValue({ isValid: true, }), checkLicense: jest.fn().mockResolvedValue({ diff --git a/x-pack/plugins/alerting/server/lib/license_state.test.ts b/x-pack/plugins/alerting/server/lib/license_state.test.ts index e20acafbab314..0a261c7248484 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.test.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AlertType } from '../types'; +import { RuleType } from '../types'; import { Subject } from 'rxjs'; import { LicenseState, ILicenseState } from './license_state'; import { licensingMock } from '../../../licensing/server/mocks'; @@ -53,11 +53,11 @@ describe('checkLicense()', () => { }); }); -describe('getLicenseCheckForAlertType', () => { +describe('getLicenseCheckForRuleType', () => { let license: Subject; let licenseState: ILicenseState; const mockNotifyUsage = jest.fn(); - const alertType: AlertType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -82,10 +82,10 @@ describe('getLicenseCheckForAlertType', () => { test('should return false when license not defined', () => { expect( - licenseState.getLicenseCheckForAlertType( - alertType.id, - alertType.name, - alertType.minimumLicenseRequired + licenseState.getLicenseCheckForRuleType( + ruleType.id, + ruleType.name, + ruleType.minimumLicenseRequired ) ).toEqual({ isValid: false, @@ -96,10 +96,10 @@ describe('getLicenseCheckForAlertType', () => { test('should return false when license not available', () => { license.next(createUnavailableLicense()); expect( - licenseState.getLicenseCheckForAlertType( - alertType.id, - alertType.name, - alertType.minimumLicenseRequired + licenseState.getLicenseCheckForRuleType( + ruleType.id, + ruleType.name, + ruleType.minimumLicenseRequired ) ).toEqual({ isValid: false, @@ -111,10 +111,10 @@ describe('getLicenseCheckForAlertType', () => { const expiredLicense = licensingMock.createLicense({ license: { status: 'expired' } }); license.next(expiredLicense); expect( - licenseState.getLicenseCheckForAlertType( - alertType.id, - alertType.name, - alertType.minimumLicenseRequired + licenseState.getLicenseCheckForRuleType( + ruleType.id, + ruleType.name, + ruleType.minimumLicenseRequired ) ).toEqual({ isValid: false, @@ -128,10 +128,10 @@ describe('getLicenseCheckForAlertType', () => { }); license.next(basicLicense); expect( - licenseState.getLicenseCheckForAlertType( - alertType.id, - alertType.name, - alertType.minimumLicenseRequired + licenseState.getLicenseCheckForRuleType( + ruleType.id, + ruleType.name, + ruleType.minimumLicenseRequired ) ).toEqual({ isValid: false, @@ -145,10 +145,10 @@ describe('getLicenseCheckForAlertType', () => { }); license.next(goldLicense); expect( - licenseState.getLicenseCheckForAlertType( - alertType.id, - alertType.name, - alertType.minimumLicenseRequired + licenseState.getLicenseCheckForRuleType( + ruleType.id, + ruleType.name, + ruleType.minimumLicenseRequired ) ).toEqual({ isValid: true, @@ -160,7 +160,7 @@ describe('getLicenseCheckForAlertType', () => { license: { status: 'active', type: 'gold' }, }); license.next(goldLicense); - licenseState.getLicenseCheckForAlertType(alertType.id, alertType.name, 'gold'); + licenseState.getLicenseCheckForRuleType(ruleType.id, ruleType.name, 'gold'); expect(mockNotifyUsage).not.toHaveBeenCalled(); }); @@ -169,7 +169,7 @@ describe('getLicenseCheckForAlertType', () => { license: { status: 'active', type: 'basic' }, }); license.next(basicLicense); - licenseState.getLicenseCheckForAlertType(alertType.id, alertType.name, 'basic'); + licenseState.getLicenseCheckForRuleType(ruleType.id, ruleType.name, 'basic'); expect(mockNotifyUsage).not.toHaveBeenCalled(); }); @@ -178,21 +178,21 @@ describe('getLicenseCheckForAlertType', () => { license: { status: 'active', type: 'gold' }, }); license.next(goldLicense); - licenseState.getLicenseCheckForAlertType( - alertType.id, - alertType.name, - alertType.minimumLicenseRequired, + licenseState.getLicenseCheckForRuleType( + ruleType.id, + ruleType.name, + ruleType.minimumLicenseRequired, { notifyUsage: true } ); - expect(mockNotifyUsage).toHaveBeenCalledWith('Alert: Test'); + expect(mockNotifyUsage).toHaveBeenCalledWith('Rule: Test'); }); }); -describe('ensureLicenseForAlertType()', () => { +describe('ensureLicenseForRuleType()', () => { let license: Subject; let licenseState: ILicenseState; const mockNotifyUsage = jest.fn(); - const alertType: AlertType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -217,18 +217,18 @@ describe('ensureLicenseForAlertType()', () => { test('should throw when license not defined', () => { expect(() => - licenseState.ensureLicenseForAlertType(alertType) + licenseState.ensureLicenseForRuleType(ruleType) ).toThrowErrorMatchingInlineSnapshot( - `"Alert type test is disabled because license information is not available at this time."` + `"Rule type test is disabled because license information is not available at this time."` ); }); test('should throw when license not available', () => { license.next(createUnavailableLicense()); expect(() => - licenseState.ensureLicenseForAlertType(alertType) + licenseState.ensureLicenseForRuleType(ruleType) ).toThrowErrorMatchingInlineSnapshot( - `"Alert type test is disabled because license information is not available at this time."` + `"Rule type test is disabled because license information is not available at this time."` ); }); @@ -236,9 +236,9 @@ describe('ensureLicenseForAlertType()', () => { const expiredLicense = licensingMock.createLicense({ license: { status: 'expired' } }); license.next(expiredLicense); expect(() => - licenseState.ensureLicenseForAlertType(alertType) + licenseState.ensureLicenseForRuleType(ruleType) ).toThrowErrorMatchingInlineSnapshot( - `"Alert type test is disabled because your basic license has expired."` + `"Rule type test is disabled because your basic license has expired."` ); }); @@ -248,9 +248,9 @@ describe('ensureLicenseForAlertType()', () => { }); license.next(basicLicense); expect(() => - licenseState.ensureLicenseForAlertType(alertType) + licenseState.ensureLicenseForRuleType(ruleType) ).toThrowErrorMatchingInlineSnapshot( - `"Alert test is disabled because it requires a Gold license. Go to License Management to view upgrade options."` + `"Rule test is disabled because it requires a Gold license. Go to License Management to view upgrade options."` ); }); @@ -259,7 +259,7 @@ describe('ensureLicenseForAlertType()', () => { license: { status: 'active', type: 'gold' }, }); license.next(goldLicense); - licenseState.ensureLicenseForAlertType(alertType); + licenseState.ensureLicenseForRuleType(ruleType); }); test('should call notifyUsage', () => { @@ -267,8 +267,8 @@ describe('ensureLicenseForAlertType()', () => { license: { status: 'active', type: 'gold' }, }); license.next(goldLicense); - licenseState.ensureLicenseForAlertType(alertType); - expect(mockNotifyUsage).toHaveBeenCalledWith('Alert: Test'); + licenseState.ensureLicenseForRuleType(ruleType); + expect(mockNotifyUsage).toHaveBeenCalledWith('Rule: Test'); }); }); diff --git a/x-pack/plugins/alerting/server/lib/license_state.ts b/x-pack/plugins/alerting/server/lib/license_state.ts index 9f6fd1b292af8..162823f8d5850 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.ts @@ -14,9 +14,9 @@ import { Observable, Subscription } from 'rxjs'; import { LicensingPluginStart } from '../../../licensing/server'; import { ILicense, LicenseType } from '../../../licensing/common/types'; import { PLUGIN } from '../constants/plugin'; -import { getAlertTypeFeatureUsageName } from './get_alert_type_feature_usage_name'; +import { getRuleTypeFeatureUsageName } from './get_rule_type_feature_usage_name'; import { - AlertType, + RuleType, AlertTypeParams, AlertTypeState, AlertInstanceState, @@ -68,21 +68,21 @@ export class LicenseState { this._notifyUsage = notifyUsage; } - public getLicenseCheckForAlertType( - alertTypeId: string, - alertTypeName: string, + public getLicenseCheckForRuleType( + ruleTypeId: string, + ruleTypeName: string, minimumLicenseRequired: LicenseType, { notifyUsage }: { notifyUsage: boolean } = { notifyUsage: false } ): { isValid: true } | { isValid: false; reason: 'unavailable' | 'expired' | 'invalid' } { if (notifyUsage) { - this.notifyUsage(alertTypeName, minimumLicenseRequired); + this.notifyUsage(ruleTypeName, minimumLicenseRequired); } if (!this.license?.isAvailable) { return { isValid: false, reason: 'unavailable' }; } - const check = this.license.check(alertTypeId, minimumLicenseRequired); + const check = this.license.check(ruleTypeId, minimumLicenseRequired); switch (check.state) { case 'expired': @@ -98,10 +98,10 @@ export class LicenseState { } } - private notifyUsage(alertTypeName: string, minimumLicenseRequired: LicenseType) { + private notifyUsage(ruleTypeName: string, minimumLicenseRequired: LicenseType) { // No need to notify usage on basic alert types if (this._notifyUsage && minimumLicenseRequired !== 'basic') { - this._notifyUsage(getAlertTypeFeatureUsageName(alertTypeName)); + this._notifyUsage(getRuleTypeFeatureUsageName(ruleTypeName)); } } @@ -147,7 +147,7 @@ export class LicenseState { } } - public ensureLicenseForAlertType< + public ensureLicenseForRuleType< Params extends AlertTypeParams, ExtractedParams extends AlertTypeParams, State extends AlertTypeState, @@ -156,7 +156,7 @@ export class LicenseState { ActionGroupIds extends string, RecoveryActionGroupId extends string >( - alertType: AlertType< + ruleType: RuleType< Params, ExtractedParams, State, @@ -166,12 +166,12 @@ export class LicenseState { RecoveryActionGroupId > ) { - this.notifyUsage(alertType.name, alertType.minimumLicenseRequired); + this.notifyUsage(ruleType.name, ruleType.minimumLicenseRequired); - const check = this.getLicenseCheckForAlertType( - alertType.id, - alertType.name, - alertType.minimumLicenseRequired + const check = this.getLicenseCheckForRuleType( + ruleType.id, + ruleType.name, + ruleType.minimumLicenseRequired ); if (check.isValid) { @@ -182,9 +182,9 @@ export class LicenseState { throw new AlertTypeDisabledError( i18n.translate('xpack.alerting.serverSideErrors.unavailableLicenseErrorMessage', { defaultMessage: - 'Alert type {alertTypeId} is disabled because license information is not available at this time.', + 'Rule type {ruleTypeId} is disabled because license information is not available at this time.', values: { - alertTypeId: alertType.id, + ruleTypeId: ruleType.id, }, }), 'license_unavailable' @@ -193,8 +193,8 @@ export class LicenseState { throw new AlertTypeDisabledError( i18n.translate('xpack.alerting.serverSideErrors.expirerdLicenseErrorMessage', { defaultMessage: - 'Alert type {alertTypeId} is disabled because your {licenseType} license has expired.', - values: { alertTypeId: alertType.id, licenseType: this.license!.type }, + 'Rule type {ruleTypeId} is disabled because your {licenseType} license has expired.', + values: { ruleTypeId: ruleType.id, licenseType: this.license!.type }, }), 'license_expired' ); @@ -202,10 +202,10 @@ export class LicenseState { throw new AlertTypeDisabledError( i18n.translate('xpack.alerting.serverSideErrors.invalidLicenseErrorMessage', { defaultMessage: - 'Alert {alertTypeId} is disabled because it requires a {licenseType} license. Go to License Management to view upgrade options.', + 'Rule {ruleTypeId} is disabled because it requires a {licenseType} license. Go to License Management to view upgrade options.', values: { - alertTypeId: alertType.id, - licenseType: capitalize(alertType.minimumLicenseRequired), + ruleTypeId: ruleType.id, + licenseType: capitalize(ruleType.minimumLicenseRequired), }, }), 'license_invalid' diff --git a/x-pack/plugins/alerting/server/lib/alert_execution_status.test.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts similarity index 82% rename from x-pack/plugins/alerting/server/lib/alert_execution_status.test.ts rename to x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts index 93cf0c656c692..4ee5bbe48c162 100644 --- a/x-pack/plugins/alerting/server/lib/alert_execution_status.test.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts @@ -10,14 +10,14 @@ import { AlertExecutionStatusErrorReasons } from '../types'; import { executionStatusFromState, executionStatusFromError, - alertExecutionStatusToRaw, - alertExecutionStatusFromRaw, -} from './alert_execution_status'; + ruleExecutionStatusToRaw, + ruleExecutionStatusFromRaw, +} from './rule_execution_status'; import { ErrorWithReason } from './error_with_reason'; const MockLogger = loggingSystemMock.create().get(); -describe('AlertExecutionStatus', () => { +describe('RuleExecutionStatus', () => { beforeEach(() => { jest.resetAllMocks(); }); @@ -71,14 +71,14 @@ describe('AlertExecutionStatus', () => { }); }); - describe('alertExecutionStatusToRaw()', () => { + describe('ruleExecutionStatusToRaw()', () => { const date = new Date('2020-09-03T16:26:58Z'); const status = 'ok'; const reason = AlertExecutionStatusErrorReasons.Decrypt; const error = { reason, message: 'wops' }; test('status without an error', () => { - expect(alertExecutionStatusToRaw({ lastExecutionDate: date, status })).toMatchInlineSnapshot(` + expect(ruleExecutionStatusToRaw({ lastExecutionDate: date, status })).toMatchInlineSnapshot(` Object { "error": null, "lastDuration": 0, @@ -89,7 +89,7 @@ describe('AlertExecutionStatus', () => { }); test('status with an error', () => { - expect(alertExecutionStatusToRaw({ lastExecutionDate: date, status, error })) + expect(ruleExecutionStatusToRaw({ lastExecutionDate: date, status, error })) .toMatchInlineSnapshot(` Object { "error": Object { @@ -104,7 +104,7 @@ describe('AlertExecutionStatus', () => { }); test('status with a duration', () => { - expect(alertExecutionStatusToRaw({ lastExecutionDate: date, status, lastDuration: 1234 })) + expect(ruleExecutionStatusToRaw({ lastExecutionDate: date, status, lastDuration: 1234 })) .toMatchInlineSnapshot(` Object { "error": null, @@ -116,41 +116,41 @@ describe('AlertExecutionStatus', () => { }); }); - describe('alertExecutionStatusFromRaw()', () => { + describe('ruleExecutionStatusFromRaw()', () => { const date = new Date('2020-09-03T16:26:58Z').toISOString(); const status = 'active'; const reason = AlertExecutionStatusErrorReasons.Execute; const error = { reason, message: 'wops' }; test('no input', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id'); + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id'); expect(result).toBe(undefined); }); test('undefined input', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', undefined); + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id', undefined); expect(result).toBe(undefined); }); test('null input', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', null); + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id', null); expect(result).toBe(undefined); }); test('invalid date', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', { + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id', { lastExecutionDate: 'an invalid date', })!; checkDateIsNearNow(result.lastExecutionDate); expect(result.status).toBe('unknown'); expect(result.error).toBe(undefined); expect(MockLogger.debug).toBeCalledWith( - 'invalid alertExecutionStatus lastExecutionDate "an invalid date" in raw alert alert-id' + 'invalid ruleExecutionStatus lastExecutionDate "an invalid date" in raw rule rule-id' ); }); test('valid date', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', { + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id', { lastExecutionDate: date, }); expect(result).toMatchInlineSnapshot(` @@ -162,7 +162,7 @@ describe('AlertExecutionStatus', () => { }); test('valid status and date', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', { + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id', { status, lastExecutionDate: date, }); @@ -175,7 +175,7 @@ describe('AlertExecutionStatus', () => { }); test('valid status, date and error', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', { + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id', { status, lastExecutionDate: date, error, @@ -193,7 +193,7 @@ describe('AlertExecutionStatus', () => { }); test('valid status, date and duration', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', { + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id', { status, lastExecutionDate: date, lastDuration: 1234, @@ -208,7 +208,7 @@ describe('AlertExecutionStatus', () => { }); test('valid status, date, error and duration', () => { - const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', { + const result = ruleExecutionStatusFromRaw(MockLogger, 'rule-id', { status, lastExecutionDate: date, error, diff --git a/x-pack/plugins/alerting/server/lib/alert_execution_status.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts similarity index 68% rename from x-pack/plugins/alerting/server/lib/alert_execution_status.ts rename to x-pack/plugins/alerting/server/lib/rule_execution_status.ts index 82d8514331704..f631884de12c5 100644 --- a/x-pack/plugins/alerting/server/lib/alert_execution_status.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts @@ -6,16 +6,16 @@ */ import { Logger } from 'src/core/server'; -import { AlertTaskState, AlertExecutionStatus, RawAlertExecutionStatus } from '../types'; +import { RuleTaskState, AlertExecutionStatus, RawRuleExecutionStatus } from '../types'; import { getReasonFromError } from './error_with_reason'; import { getEsErrorMessage } from './errors'; import { AlertExecutionStatuses } from '../../common'; -export function executionStatusFromState(state: AlertTaskState): AlertExecutionStatus { - const instanceIds = Object.keys(state.alertInstances ?? {}); +export function executionStatusFromState(state: RuleTaskState): AlertExecutionStatus { + const alertIds = Object.keys(state.alertInstances ?? {}); return { lastExecutionDate: new Date(), - status: instanceIds.length === 0 ? 'ok' : 'active', + status: alertIds.length === 0 ? 'ok' : 'active', }; } @@ -30,12 +30,12 @@ export function executionStatusFromError(error: Error): AlertExecutionStatus { }; } -export function alertExecutionStatusToRaw({ +export function ruleExecutionStatusToRaw({ lastExecutionDate, lastDuration, status, error, -}: AlertExecutionStatus): RawAlertExecutionStatus { +}: AlertExecutionStatus): RawRuleExecutionStatus { return { lastExecutionDate: lastExecutionDate.toISOString(), lastDuration: lastDuration ?? 0, @@ -45,19 +45,19 @@ export function alertExecutionStatusToRaw({ }; } -export function alertExecutionStatusFromRaw( +export function ruleExecutionStatusFromRaw( logger: Logger, - alertId: string, - rawAlertExecutionStatus?: Partial | null | undefined + ruleId: string, + rawRuleExecutionStatus?: Partial | null | undefined ): AlertExecutionStatus | undefined { - if (!rawAlertExecutionStatus) return undefined; + if (!rawRuleExecutionStatus) return undefined; - const { lastExecutionDate, lastDuration, status = 'unknown', error } = rawAlertExecutionStatus; + const { lastExecutionDate, lastDuration, status = 'unknown', error } = rawRuleExecutionStatus; let parsedDateMillis = lastExecutionDate ? Date.parse(lastExecutionDate) : Date.now(); if (isNaN(parsedDateMillis)) { logger.debug( - `invalid alertExecutionStatus lastExecutionDate "${lastExecutionDate}" in raw alert ${alertId}` + `invalid ruleExecutionStatus lastExecutionDate "${lastExecutionDate}" in raw rule ${ruleId}` ); parsedDateMillis = Date.now(); } @@ -78,7 +78,7 @@ export function alertExecutionStatusFromRaw( return executionStatus; } -export const getAlertExecutionStatusPending = (lastExecutionDate: string) => ({ +export const getRuleExecutionStatusPending = (lastExecutionDate: string) => ({ status: 'pending' as AlertExecutionStatuses, lastExecutionDate, error: null, diff --git a/x-pack/plugins/alerting/server/lib/validate_alert_type_params.test.ts b/x-pack/plugins/alerting/server/lib/validate_rule_type_params.test.ts similarity index 85% rename from x-pack/plugins/alerting/server/lib/validate_alert_type_params.test.ts rename to x-pack/plugins/alerting/server/lib/validate_rule_type_params.test.ts index 6422b8680d0b1..d6f802f047fd2 100644 --- a/x-pack/plugins/alerting/server/lib/validate_alert_type_params.test.ts +++ b/x-pack/plugins/alerting/server/lib/validate_rule_type_params.test.ts @@ -6,17 +6,17 @@ */ import { schema } from '@kbn/config-schema'; -import { validateAlertTypeParams } from './validate_alert_type_params'; +import { validateRuleTypeParams } from './validate_rule_type_params'; test('should return passed in params when validation not defined', () => { - const result = validateAlertTypeParams({ + const result = validateRuleTypeParams({ foo: true, }); expect(result).toEqual({ foo: true }); }); test('should validate and apply defaults when params is valid', () => { - const result = validateAlertTypeParams( + const result = validateRuleTypeParams( { param1: 'value' }, schema.object({ param1: schema.string(), @@ -31,7 +31,7 @@ test('should validate and apply defaults when params is valid', () => { test('should validate and throw error when params is invalid', () => { expect(() => - validateAlertTypeParams( + validateRuleTypeParams( {}, schema.object({ param1: schema.string(), diff --git a/x-pack/plugins/alerting/server/lib/validate_alert_type_params.ts b/x-pack/plugins/alerting/server/lib/validate_rule_type_params.ts similarity index 89% rename from x-pack/plugins/alerting/server/lib/validate_alert_type_params.ts rename to x-pack/plugins/alerting/server/lib/validate_rule_type_params.ts index a2913e8fdbcf3..eef6ecb32c1b1 100644 --- a/x-pack/plugins/alerting/server/lib/validate_alert_type_params.ts +++ b/x-pack/plugins/alerting/server/lib/validate_rule_type_params.ts @@ -8,7 +8,7 @@ import Boom from '@hapi/boom'; import { AlertTypeParams, AlertTypeParamsValidator } from '../types'; -export function validateAlertTypeParams( +export function validateRuleTypeParams( params: Record, validator?: AlertTypeParamsValidator ): Params { diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index a8da891a3dd14..3716ecfcc1260 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -16,7 +16,7 @@ import { KibanaRequest } from 'kibana/server'; import { featuresPluginMock } from '../../features/server/mocks'; import { KibanaFeature } from '../../features/server'; import { AlertsConfig } from './config'; -import { AlertType } from './types'; +import { RuleType } from './types'; import { eventLogMock } from '../../event_log/server/mocks'; import { actionsMock } from '../../actions/server/mocks'; @@ -99,7 +99,7 @@ describe('Alerting Plugin', () => { describe('registerType()', () => { let setup: PluginSetupContract; - const sampleAlertType: AlertType = { + const sampleRuleType: RuleType = { id: 'test', name: 'test', minimumLicenseRequired: 'basic', @@ -126,7 +126,7 @@ describe('Alerting Plugin', () => { it('should throw error when license type is invalid', async () => { expect(() => setup.registerType({ - ...sampleAlertType, + ...sampleRuleType, // eslint-disable-next-line @typescript-eslint/no-explicit-any minimumLicenseRequired: 'foo' as any, }) @@ -135,52 +135,52 @@ describe('Alerting Plugin', () => { it('should not throw when license type is gold', async () => { setup.registerType({ - ...sampleAlertType, + ...sampleRuleType, minimumLicenseRequired: 'gold', }); }); it('should not throw when license type is basic', async () => { setup.registerType({ - ...sampleAlertType, + ...sampleRuleType, minimumLicenseRequired: 'basic', }); }); it('should apply default config value for ruleTaskTimeout if no value is specified', async () => { const ruleType = { - ...sampleAlertType, + ...sampleRuleType, minimumLicenseRequired: 'basic', - } as AlertType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.ruleTaskTimeout).toBe('5m'); }); it('should apply value for ruleTaskTimeout if specified', async () => { const ruleType = { - ...sampleAlertType, + ...sampleRuleType, minimumLicenseRequired: 'basic', ruleTaskTimeout: '20h', - } as AlertType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.ruleTaskTimeout).toBe('20h'); }); it('should apply default config value for cancelAlertsOnRuleTimeout if no value is specified', async () => { const ruleType = { - ...sampleAlertType, + ...sampleRuleType, minimumLicenseRequired: 'basic', - } as AlertType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.cancelAlertsOnRuleTimeout).toBe(true); }); it('should apply value for cancelAlertsOnRuleTimeout if specified', async () => { const ruleType = { - ...sampleAlertType, + ...sampleRuleType, minimumLicenseRequired: 'basic', cancelAlertsOnRuleTimeout: false, - } as AlertType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.cancelAlertsOnRuleTimeout).toBe(false); }); diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 8be96170e664a..b466d1b18ed70 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -46,7 +46,7 @@ import { AlertInstanceContext, AlertInstanceState, AlertsHealth, - AlertType, + RuleType, AlertTypeParams, AlertTypeState, Services, @@ -91,7 +91,7 @@ export interface PluginSetupContract { ActionGroupIds extends string = never, RecoveryActionGroupId extends string = never >( - alertType: AlertType< + ruleType: RuleType< Params, ExtractedParams, State, @@ -273,7 +273,7 @@ export class AlertingPlugin { ActionGroupIds extends string = never, RecoveryActionGroupId extends string = never >( - alertType: AlertType< + ruleType: RuleType< Params, ExtractedParams, State, @@ -283,15 +283,15 @@ export class AlertingPlugin { RecoveryActionGroupId > ) { - if (!(alertType.minimumLicenseRequired in LICENSE_TYPE)) { - throw new Error(`"${alertType.minimumLicenseRequired}" is not a valid license type`); + if (!(ruleType.minimumLicenseRequired in LICENSE_TYPE)) { + throw new Error(`"${ruleType.minimumLicenseRequired}" is not a valid license type`); } alertingConfig.then((config) => { - alertType.ruleTaskTimeout = alertType.ruleTaskTimeout ?? config.defaultRuleTaskTimeout; - alertType.cancelAlertsOnRuleTimeout = - alertType.cancelAlertsOnRuleTimeout ?? config.cancelAlertsOnRuleTimeout; - ruleTypeRegistry.register(alertType); + ruleType.ruleTaskTimeout = ruleType.ruleTaskTimeout ?? config.defaultRuleTaskTimeout; + ruleType.cancelAlertsOnRuleTimeout = + ruleType.cancelAlertsOnRuleTimeout ?? config.cancelAlertsOnRuleTimeout; + ruleTypeRegistry.register(ruleType); }); }, getSecurityHealth: async () => { @@ -390,7 +390,7 @@ export class AlertingPlugin { ruleTypeRegistry: this.ruleTypeRegistry!, kibanaBaseUrl: this.kibanaBaseUrl, supportsEphemeralTasks: plugins.taskManager.supportsEphemeralTasks(), - maxEphemeralActionsPerAlert: config.maxEphemeralActionsPerAlert, + maxEphemeralActionsPerRule: config.maxEphemeralActionsPerAlert, cancelAlertsOnRuleTimeout: config.cancelAlertsOnRuleTimeout, }); }); diff --git a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts index c19beee0e841f..c94d46e3a4558 100644 --- a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts @@ -10,7 +10,7 @@ import { identity } from 'lodash'; import type { MethodKeysOf } from '@kbn/utility-types'; import { httpServerMock } from '../../../../../src/core/server/mocks'; import { rulesClientMock, RulesClientMock } from '../rules_client.mock'; -import { AlertsHealth, AlertType } from '../../common'; +import { AlertsHealth, RuleType } from '../../common'; import type { AlertingRequestHandlerContext } from '../types'; export function mockHandlerArguments( @@ -21,7 +21,7 @@ export function mockHandlerArguments( areApiKeysEnabled, }: { rulesClient?: RulesClientMock; - listTypes?: AlertType[]; + listTypes?: RuleType[]; getFrameworkHealth?: jest.MockInstance, []> & (() => Promise); areApiKeysEnabled?: () => Promise; diff --git a/x-pack/plugins/alerting/server/routes/get_rule_state.ts b/x-pack/plugins/alerting/server/routes/get_rule_state.ts index 6337bed39f150..1ebe3ab367443 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_state.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_state.ts @@ -12,14 +12,14 @@ import { RewriteResponseCase, verifyAccessAndContext } from './lib'; import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH, - AlertTaskState, + RuleTaskState, } from '../types'; const paramSchema = schema.object({ id: schema.string(), }); -const rewriteBodyRes: RewriteResponseCase = ({ +const rewriteBodyRes: RewriteResponseCase = ({ alertTypeState, alertInstances, previousStartedAt, 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 895a5047339ef..e23c7f25a4f76 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -7,7 +7,7 @@ import { TaskRunnerFactory } from './task_runner'; import { RuleTypeRegistry, ConstructorOptions } from './rule_type_registry'; -import { ActionGroup, AlertType } from './types'; +import { ActionGroup, RuleType } from './types'; import { taskManagerMock } from '../../task_manager/server/mocks'; import { ILicenseState } from './lib/license_state'; import { licenseStateMock } from './lib/license_state.mock'; @@ -56,8 +56,8 @@ describe('has()', () => { }); describe('register()', () => { - test('throws if AlertType Id contains invalid characters', () => { - const alertType: AlertType = { + test('throws if RuleType Id contains invalid characters', () => { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -76,21 +76,21 @@ describe('register()', () => { const invalidCharacters = [' ', ':', '*', '*', '/']; for (const char of invalidCharacters) { - expect(() => registry.register({ ...alertType, id: `${alertType.id}${char}` })).toThrowError( - new Error(`expected AlertType Id not to include invalid character: ${char}`) + expect(() => registry.register({ ...ruleType, id: `${ruleType.id}${char}` })).toThrowError( + new Error(`expected RuleType Id not to include invalid character: ${char}`) ); } const [first, second] = invalidCharacters; expect(() => - registry.register({ ...alertType, id: `${first}${alertType.id}${second}` }) + registry.register({ ...ruleType, id: `${first}${ruleType.id}${second}` }) ).toThrowError( - new Error(`expected AlertType Id not to include invalid characters: ${first}, ${second}`) + new Error(`expected RuleType Id not to include invalid characters: ${first}, ${second}`) ); }); - test('throws if AlertType Id isnt a string', () => { - const alertType: AlertType = { + test('throws if RuleType Id isnt a string', () => { + const ruleType: RuleType = { id: 123 as unknown as string, name: 'Test', actionGroups: [ @@ -107,13 +107,13 @@ describe('register()', () => { }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - expect(() => registry.register(alertType)).toThrowError( + expect(() => registry.register(ruleType)).toThrowError( new Error(`expected value of type [string] but got [number]`) ); }); - test('throws if AlertType ruleTaskTimeout is not a valid duration', () => { - const alertType: AlertType = { + test('throws if RuleType ruleTaskTimeout is not a valid duration', () => { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -131,7 +131,7 @@ describe('register()', () => { }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - expect(() => registry.register(alertType)).toThrowError( + expect(() => registry.register(ruleType)).toThrowError( new Error( `Rule type \"123\" has invalid timeout: string is not a valid duration: 23 milisec.` ) @@ -139,7 +139,7 @@ describe('register()', () => { }); test('throws if defaultScheduleInterval isnt valid', () => { - const alertType: AlertType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -158,7 +158,7 @@ describe('register()', () => { }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - expect(() => registry.register(alertType)).toThrowError( + expect(() => registry.register(ruleType)).toThrowError( new Error( `Rule type \"123\" has invalid default interval: string is not a valid duration: foobar.` ) @@ -166,7 +166,7 @@ describe('register()', () => { }); test('throws if minimumScheduleInterval isnt valid', () => { - const alertType: AlertType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -184,7 +184,7 @@ describe('register()', () => { }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - expect(() => registry.register(alertType)).toThrowError( + expect(() => registry.register(ruleType)).toThrowError( new Error( `Rule type \"123\" has invalid minimum interval: string is not a valid duration: foobar.` ) @@ -192,7 +192,7 @@ describe('register()', () => { }); test('throws if RuleType action groups contains reserved group id', () => { - const alertType: AlertType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -217,15 +217,15 @@ describe('register()', () => { }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - expect(() => registry.register(alertType)).toThrowError( + expect(() => registry.register(ruleType)).toThrowError( new Error( - `Rule type [id="${alertType.id}"] cannot be registered. Action groups [recovered] are reserved by the framework.` + `Rule type [id="${ruleType.id}"] cannot be registered. Action groups [recovered] are reserved by the framework.` ) ); }); - test('allows an AlertType to specify a custom recovery group', () => { - const alertType: AlertType = { + test('allows an RuleType to specify a custom recovery group', () => { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -245,7 +245,7 @@ describe('register()', () => { isExportable: true, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(alertType); + registry.register(ruleType); expect(registry.get('test').actionGroups).toMatchInlineSnapshot(` Array [ Object { @@ -260,8 +260,8 @@ describe('register()', () => { `); }); - test('allows an AlertType to specify a custom rule task timeout', () => { - const alertType: AlertType = { + test('allows an RuleType to specify a custom rule task timeout', () => { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -278,12 +278,12 @@ describe('register()', () => { isExportable: true, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(alertType); + registry.register(ruleType); expect(registry.get('test').ruleTaskTimeout).toBe('13m'); }); - test('throws if the custom recovery group is contained in the AlertType action groups', () => { - const alertType: AlertType< + test('throws if the custom recovery group is contained in the RuleType action groups', () => { + const ruleType: RuleType< never, never, never, @@ -316,15 +316,15 @@ describe('register()', () => { }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - expect(() => registry.register(alertType)).toThrowError( + expect(() => registry.register(ruleType)).toThrowError( new Error( - `Rule type [id="${alertType.id}"] cannot be registered. Action group [backToAwesome] cannot be used as both a recovery and an active action group.` + `Rule type [id="${ruleType.id}"] cannot be registered. Action group [backToAwesome] cannot be used as both a recovery and an active action group.` ) ); }); test('registers the executor with the task manager', () => { - const alertType: AlertType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -341,7 +341,7 @@ describe('register()', () => { ruleTaskTimeout: '20m', }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(alertType); + registry.register(ruleType); expect(taskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); expect(taskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -357,7 +357,7 @@ describe('register()', () => { }); test('shallow clones the given rule type', () => { - const alertType: AlertType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -373,8 +373,8 @@ describe('register()', () => { producer: 'alerts', }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(alertType); - alertType.name = 'Changed'; + registry.register(ruleType); + ruleType.name = 'Changed'; expect(registry.get('test').name).toEqual('Test'); }); @@ -433,8 +433,8 @@ describe('get()', () => { executor: jest.fn(), producer: 'alerts', }); - const alertType = registry.get('test'); - expect(alertType).toMatchInlineSnapshot(` + const ruleType = registry.get('test'); + expect(ruleType).toMatchInlineSnapshot(` Object { "actionGroups": Array [ Object { @@ -539,12 +539,12 @@ describe('list()', () => { test('should return action variables state and empty context', () => { const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(alertTypeWithVariables('x', '', 's')); - const alertType = registry.get('x'); - expect(alertType.actionVariables).toBeTruthy(); + registry.register(ruleTypeWithVariables('x', '', 's')); + const ruleType = registry.get('x'); + expect(ruleType.actionVariables).toBeTruthy(); - const context = alertType.actionVariables!.context; - const state = alertType.actionVariables!.state; + const context = ruleType.actionVariables!.context; + const state = ruleType.actionVariables!.state; expect(context).toBeTruthy(); expect(context!.length).toBe(0); @@ -556,12 +556,12 @@ describe('list()', () => { test('should return action variables context and empty state', () => { const registry = new RuleTypeRegistry(ruleTypeRegistryParams); - registry.register(alertTypeWithVariables('x', 'c', '')); - const alertType = registry.get('x'); - expect(alertType.actionVariables).toBeTruthy(); + registry.register(ruleTypeWithVariables('x', 'c', '')); + const ruleType = registry.get('x'); + expect(ruleType.actionVariables).toBeTruthy(); - const context = alertType.actionVariables!.context; - const state = alertType.actionVariables!.state; + const context = ruleType.actionVariables!.context; + const state = ruleType.actionVariables!.state; expect(state).toBeTruthy(); expect(state!.length).toBe(0); @@ -597,11 +597,11 @@ describe('ensureRuleTypeEnabled', () => { test('should call ensureLicenseForAlertType on the license state', async () => { ruleTypeRegistry.ensureRuleTypeEnabled('test'); - expect(mockedLicenseState.ensureLicenseForAlertType).toHaveBeenCalled(); + expect(mockedLicenseState.ensureLicenseForRuleType).toHaveBeenCalled(); }); test('should throw when ensureLicenseForAlertType throws', async () => { - mockedLicenseState.ensureLicenseForAlertType.mockImplementation(() => { + mockedLicenseState.ensureLicenseForRuleType.mockImplementation(() => { throw new Error('Fail'); }); expect(() => ruleTypeRegistry.ensureRuleTypeEnabled('test')).toThrowErrorMatchingInlineSnapshot( @@ -610,12 +610,12 @@ describe('ensureRuleTypeEnabled', () => { }); }); -function alertTypeWithVariables( +function ruleTypeWithVariables( id: ActionGroupIds, context: string, state: string -): AlertType { - const baseAlert: AlertType = { +): RuleType { + const baseAlert: RuleType = { id, name: `${id}-name`, actionGroups: [], diff --git a/x-pack/plugins/alerting/server/rule_type_registry.ts b/x-pack/plugins/alerting/server/rule_type_registry.ts index 452729a9a01e9..9b4f94f3510be 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.ts @@ -14,7 +14,7 @@ import { LicensingPluginSetup } from '../../licensing/server'; import { RunContext, TaskManagerSetupContract } from '../../task_manager/server'; import { TaskRunnerFactory } from './task_runner'; import { - AlertType, + RuleType, AlertTypeParams, AlertTypeState, AlertInstanceState, @@ -28,7 +28,7 @@ import { validateDurationSchema, } from '../common'; import { ILicenseState } from './lib/license_state'; -import { getAlertTypeFeatureUsageName } from './lib/get_alert_type_feature_usage_name'; +import { getRuleTypeFeatureUsageName } from './lib/get_rule_type_feature_usage_name'; export interface ConstructorOptions { taskManager: TaskManagerSetupContract; @@ -39,7 +39,7 @@ export interface ConstructorOptions { export interface RegistryRuleType extends Pick< - UntypedNormalizedAlertType, + UntypedNormalizedRuleType, | 'name' | 'actionGroups' | 'recoveryActionGroup' @@ -57,26 +57,26 @@ export interface RegistryRuleType } /** - * AlertType IDs are used as part of the authorization strings used to + * RuleType IDs are used as part of the authorization strings used to * grant users privileged operations. There is a limited range of characters * we can use in these auth strings, so we apply these same limitations to - * the AlertType Ids. + * the RuleType Ids. * If you wish to change this, please confer with the Kibana security team. */ -const alertIdSchema = schema.string({ +const ruleTypeIdSchema = schema.string({ validate(value: string): string | void { if (typeof value !== 'string') { - return `expected AlertType Id of type [string] but got [${typeDetect(value)}]`; + return `expected RuleType Id of type [string] but got [${typeDetect(value)}]`; } else if (!value.match(/^[a-zA-Z0-9_\-\.]*$/)) { const invalid = value.match(/[^a-zA-Z0-9_\-\.]+/g)!; - return `expected AlertType Id not to include invalid character${ + return `expected RuleType Id not to include invalid character${ invalid.length > 1 ? `s` : `` }: ${invalid?.join(`, `)}`; } }, }); -export type NormalizedAlertType< +export type NormalizedRuleType< Params extends AlertTypeParams, ExtractedParams extends AlertTypeParams, State extends AlertTypeState, @@ -87,7 +87,7 @@ export type NormalizedAlertType< > = { actionGroups: Array>; } & Omit< - AlertType< + RuleType< Params, ExtractedParams, State, @@ -100,7 +100,7 @@ export type NormalizedAlertType< > & Pick< Required< - AlertType< + RuleType< Params, ExtractedParams, State, @@ -113,7 +113,7 @@ export type NormalizedAlertType< 'recoveryActionGroup' >; -export type UntypedNormalizedAlertType = NormalizedAlertType< +export type UntypedNormalizedRuleType = NormalizedRuleType< AlertTypeParams, AlertTypeParams, AlertTypeState, @@ -125,7 +125,7 @@ export type UntypedNormalizedAlertType = NormalizedAlertType< export class RuleTypeRegistry { private readonly taskManager: TaskManagerSetupContract; - private readonly ruleTypes: Map = new Map(); + private readonly ruleTypes: Map = new Map(); private readonly taskRunnerFactory: TaskRunnerFactory; private readonly licenseState: ILicenseState; private readonly licensing: LicensingPluginSetup; @@ -142,7 +142,7 @@ export class RuleTypeRegistry { } public ensureRuleTypeEnabled(id: string) { - this.licenseState.ensureLicenseForAlertType(this.get(id)); + this.licenseState.ensureLicenseForRuleType(this.get(id)); } public register< @@ -154,7 +154,7 @@ export class RuleTypeRegistry { ActionGroupIds extends string, RecoveryActionGroupId extends string >( - alertType: AlertType< + ruleType: RuleType< Params, ExtractedParams, State, @@ -164,44 +164,44 @@ export class RuleTypeRegistry { RecoveryActionGroupId > ) { - if (this.has(alertType.id)) { + if (this.has(ruleType.id)) { throw new Error( - i18n.translate('xpack.alerting.ruleTypeRegistry.register.duplicateAlertTypeError', { + i18n.translate('xpack.alerting.ruleTypeRegistry.register.duplicateRuleTypeError', { defaultMessage: 'Rule type "{id}" is already registered.', values: { - id: alertType.id, + id: ruleType.id, }, }) ); } // validate ruleTypeTimeout here - if (alertType.ruleTaskTimeout) { - const invalidTimeout = validateDurationSchema(alertType.ruleTaskTimeout); + if (ruleType.ruleTaskTimeout) { + const invalidTimeout = validateDurationSchema(ruleType.ruleTaskTimeout); if (invalidTimeout) { throw new Error( - i18n.translate('xpack.alerting.ruleTypeRegistry.register.invalidTimeoutAlertTypeError', { + i18n.translate('xpack.alerting.ruleTypeRegistry.register.invalidTimeoutRuleTypeError', { defaultMessage: 'Rule type "{id}" has invalid timeout: {errorMessage}.', values: { - id: alertType.id, + id: ruleType.id, errorMessage: invalidTimeout, }, }) ); } } - alertType.actionVariables = normalizedActionVariables(alertType.actionVariables); + ruleType.actionVariables = normalizedActionVariables(ruleType.actionVariables); // validate defaultScheduleInterval here - if (alertType.defaultScheduleInterval) { - const invalidDefaultTimeout = validateDurationSchema(alertType.defaultScheduleInterval); + if (ruleType.defaultScheduleInterval) { + const invalidDefaultTimeout = validateDurationSchema(ruleType.defaultScheduleInterval); if (invalidDefaultTimeout) { throw new Error( i18n.translate( - 'xpack.alerting.ruleTypeRegistry.register.invalidDefaultTimeoutAlertTypeError', + 'xpack.alerting.ruleTypeRegistry.register.invalidDefaultTimeoutRuleTypeError', { defaultMessage: 'Rule type "{id}" has invalid default interval: {errorMessage}.', values: { - id: alertType.id, + id: ruleType.id, errorMessage: invalidDefaultTimeout, }, } @@ -211,16 +211,16 @@ export class RuleTypeRegistry { } // validate minimumScheduleInterval here - if (alertType.minimumScheduleInterval) { - const invalidMinimumTimeout = validateDurationSchema(alertType.minimumScheduleInterval); + if (ruleType.minimumScheduleInterval) { + const invalidMinimumTimeout = validateDurationSchema(ruleType.minimumScheduleInterval); if (invalidMinimumTimeout) { throw new Error( i18n.translate( - 'xpack.alerting.ruleTypeRegistry.register.invalidMinimumTimeoutAlertTypeError', + 'xpack.alerting.ruleTypeRegistry.register.invalidMinimumTimeoutRuleTypeError', { defaultMessage: 'Rule type "{id}" has invalid minimum interval: {errorMessage}.', values: { - id: alertType.id, + id: ruleType.id, errorMessage: invalidMinimumTimeout, }, } @@ -229,7 +229,7 @@ export class RuleTypeRegistry { } } - const normalizedAlertType = augmentActionGroupsWithReserved< + const normalizedRuleType = augmentActionGroupsWithReserved< Params, ExtractedParams, State, @@ -237,17 +237,17 @@ export class RuleTypeRegistry { InstanceContext, ActionGroupIds, RecoveryActionGroupId - >(alertType); + >(ruleType); this.ruleTypes.set( - alertIdSchema.validate(alertType.id), - /** stripping the typing is required in order to store the AlertTypes in a Map */ - normalizedAlertType as unknown as UntypedNormalizedAlertType + ruleTypeIdSchema.validate(ruleType.id), + /** stripping the typing is required in order to store the RuleTypes in a Map */ + normalizedRuleType as unknown as UntypedNormalizedRuleType ); this.taskManager.registerTaskDefinitions({ - [`alerting:${alertType.id}`]: { - title: alertType.name, - timeout: alertType.ruleTaskTimeout, + [`alerting:${ruleType.id}`]: { + title: ruleType.name, + timeout: ruleType.ruleTaskTimeout, createTaskRunner: (context: RunContext) => this.taskRunnerFactory.create< Params, @@ -257,14 +257,14 @@ export class RuleTypeRegistry { InstanceContext, ActionGroupIds, RecoveryActionGroupId | RecoveredActionGroupId - >(normalizedAlertType, context), + >(normalizedRuleType, context), }, }); // No need to notify usage on basic alert types - if (alertType.minimumLicenseRequired !== 'basic') { + if (ruleType.minimumLicenseRequired !== 'basic') { this.licensing.featureUsage.register( - getAlertTypeFeatureUsageName(alertType.name), - alertType.minimumLicenseRequired + getRuleTypeFeatureUsageName(ruleType.name), + ruleType.minimumLicenseRequired ); } } @@ -279,7 +279,7 @@ export class RuleTypeRegistry { RecoveryActionGroupId extends string = string >( id: string - ): NormalizedAlertType< + ): NormalizedRuleType< Params, ExtractedParams, State, @@ -290,7 +290,7 @@ export class RuleTypeRegistry { > { if (!this.has(id)) { throw Boom.badRequest( - i18n.translate('xpack.alerting.ruleTypeRegistry.get.missingAlertTypeError', { + i18n.translate('xpack.alerting.ruleTypeRegistry.get.missingRuleTypeError', { defaultMessage: 'Rule type "{id}" is not registered.', values: { id, @@ -299,11 +299,11 @@ export class RuleTypeRegistry { ); } /** - * When we store the AlertTypes in the Map we strip the typing. - * This means that returning a typed AlertType in `get` is an inherently + * When we store the RuleTypes in the Map we strip the typing. + * This means that returning a typed RuleType in `get` is an inherently * unsafe operation. Down casting to `unknown` is the only way to achieve this. */ - return this.ruleTypes.get(id)! as unknown as NormalizedAlertType< + return this.ruleTypes.get(id)! as unknown as NormalizedRuleType< Params, ExtractedParams, State, @@ -332,7 +332,7 @@ export class RuleTypeRegistry { minimumScheduleInterval, defaultScheduleInterval, }, - ]: [string, UntypedNormalizedAlertType]) => ({ + ]: [string, UntypedNormalizedRuleType]) => ({ id, name, actionGroups, @@ -345,7 +345,7 @@ export class RuleTypeRegistry { ruleTaskTimeout, minimumScheduleInterval, defaultScheduleInterval, - enabledInLicense: !!this.licenseState.getLicenseCheckForAlertType( + enabledInLicense: !!this.licenseState.getLicenseCheckForRuleType( id, name, minimumLicenseRequired @@ -356,7 +356,7 @@ export class RuleTypeRegistry { } } -function normalizedActionVariables(actionVariables: AlertType['actionVariables']) { +function normalizedActionVariables(actionVariables: RuleType['actionVariables']) { return { context: actionVariables?.context ?? [], state: actionVariables?.state ?? [], @@ -373,7 +373,7 @@ function augmentActionGroupsWithReserved< ActionGroupIds extends string, RecoveryActionGroupId extends string >( - alertType: AlertType< + ruleType: RuleType< Params, ExtractedParams, State, @@ -382,7 +382,7 @@ function augmentActionGroupsWithReserved< ActionGroupIds, RecoveryActionGroupId > -): NormalizedAlertType< +): NormalizedRuleType< Params, ExtractedParams, State, @@ -391,8 +391,8 @@ function augmentActionGroupsWithReserved< ActionGroupIds, RecoveredActionGroupId | RecoveryActionGroupId > { - const reservedActionGroups = getBuiltinActionGroups(alertType.recoveryActionGroup); - const { id, actionGroups, recoveryActionGroup } = alertType; + const reservedActionGroups = getBuiltinActionGroups(ruleType.recoveryActionGroup); + const { id, actionGroups, recoveryActionGroup } = ruleType; const activeActionGroups = new Set(actionGroups.map((item) => item.id)); const intersectingReservedActionGroups = intersection( @@ -427,7 +427,7 @@ function augmentActionGroupsWithReserved< } return { - ...alertType, + ...ruleType, actionGroups: [...actionGroups, ...reservedActionGroups], recoveryActionGroup: recoveryActionGroup ?? RecoveredActionGroup, }; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 674f659ba6a87..e182a5c2b0058 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -24,27 +24,23 @@ import { ActionsClient, ActionsAuthorization } from '../../../actions/server'; import { Alert, PartialAlert, - RawAlert, + RawRule, RuleTypeRegistry, AlertAction, IntervalSchedule, SanitizedAlert, - AlertTaskState, + RuleTaskState, AlertSummary, AlertExecutionStatusValues, AlertNotifyWhenType, AlertTypeParams, ResolvedSanitizedRule, AlertWithLegacyId, - SanitizedAlertWithLegacyId, + SanitizedRuleWithLegacyId, PartialAlertWithLegacyId, RawAlertInstance, } from '../types'; -import { - validateAlertTypeParams, - alertExecutionStatusFromRaw, - getAlertNotifyWhenType, -} from '../lib'; +import { validateRuleTypeParams, ruleExecutionStatusFromRaw, getAlertNotifyWhenType } from '../lib'; import { GrantAPIKeyResult as SecurityPluginGrantAPIKeyResult, InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, @@ -52,7 +48,7 @@ import { import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { TaskManagerStartContract } from '../../../task_manager/server'; import { taskInstanceToAlertTaskInstance } from '../task_runner/alert_task_instance'; -import { RegistryRuleType, UntypedNormalizedAlertType } from '../rule_type_registry'; +import { RegistryRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { AlertingAuthorization, WriteOperations, @@ -77,7 +73,7 @@ import { markApiKeyForInvalidation } from '../invalidate_pending_api_keys/mark_a import { ruleAuditEvent, RuleAuditAction } from './audit_events'; import { KueryNode, nodeBuilder } from '../../../../../src/plugins/data/common'; import { mapSortField } from './lib'; -import { getAlertExecutionStatusPending } from '../lib/alert_execution_status'; +import { getRuleExecutionStatusPending } from '../lib/rule_execution_status'; import { AlertInstance } from '../alert_instance'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; @@ -316,10 +312,7 @@ export class RulesClient { // Throws an error if alert type isn't registered const ruleType = this.ruleTypeRegistry.get(data.alertTypeId); - const validatedAlertTypeParams = validateAlertTypeParams( - data.params, - ruleType.validate?.params - ); + const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params); const username = await this.getUserName(); let createdAPIKey = null; @@ -355,7 +348,7 @@ export class RulesClient { const legacyId = Semver.lt(this.kibanaVersion, '8.0.0') ? id : null; const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); - const rawAlert: RawAlert = { + const rawRule: RawRule = { ...data, ...this.apiKeyAsAlertAttributes(createdAPIKey, username), legacyId, @@ -364,11 +357,11 @@ export class RulesClient { updatedBy: username, createdAt: new Date(createTime).toISOString(), updatedAt: new Date(createTime).toISOString(), - params: updatedParams as RawAlert['params'], + params: updatedParams as RawRule['params'], muteAll: false, mutedInstanceIds: [], notifyWhen, - executionStatus: getAlertExecutionStatusPending(new Date().toISOString()), + executionStatus: getRuleExecutionStatusPending(new Date().toISOString()), }; this.auditLogger?.log( @@ -379,11 +372,11 @@ export class RulesClient { }) ); - let createdAlert: SavedObject; + let createdAlert: SavedObject; try { createdAlert = await this.unsecuredSavedObjectsClient.create( 'alert', - this.updateMeta(rawAlert), + this.updateMeta(rawRule), { ...options, references, @@ -393,7 +386,7 @@ export class RulesClient { } catch (e) { // Avoid unused API key markApiKeyForInvalidation( - { apiKey: rawAlert.apiKey }, + { apiKey: rawRule.apiKey }, this.logger, this.unsecuredSavedObjectsClient ); @@ -404,7 +397,7 @@ export class RulesClient { try { scheduledTask = await this.scheduleRule( createdAlert.id, - rawAlert.alertTypeId, + rawRule.alertTypeId, data.schedule, true ); @@ -420,7 +413,7 @@ export class RulesClient { } throw e; } - await this.unsecuredSavedObjectsClient.update('alert', createdAlert.id, { + await this.unsecuredSavedObjectsClient.update('alert', createdAlert.id, { scheduledTaskId: scheduledTask.id, }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; @@ -439,8 +432,8 @@ export class RulesClient { }: { id: string; includeLegacyId?: boolean; - }): Promise | SanitizedAlertWithLegacyId> { - const result = await this.unsecuredSavedObjectsClient.get('alert', id); + }): Promise | SanitizedRuleWithLegacyId> { + const result = await this.unsecuredSavedObjectsClient.get('alert', id); try { await this.authorization.ensureAuthorized({ ruleTypeId: result.attributes.alertTypeId, @@ -481,7 +474,7 @@ export class RulesClient { includeLegacyId?: boolean; }): Promise> { const { saved_object: result, ...resolveResponse } = - await this.unsecuredSavedObjectsClient.resolve('alert', id); + await this.unsecuredSavedObjectsClient.resolve('alert', id); try { await this.authorization.ensureAuthorized({ ruleTypeId: result.attributes.alertTypeId, @@ -520,7 +513,7 @@ export class RulesClient { }; } - public async getAlertState({ id }: { id: string }): Promise { + public async getAlertState({ id }: { id: string }): Promise { const alert = await this.get({ id }); await this.authorization.ensureAuthorized({ ruleTypeId: alert.alertTypeId, @@ -539,7 +532,7 @@ export class RulesClient { public async getAlertSummary({ id, dateStart }: GetAlertSummaryParams): Promise { this.logger.debug(`getAlertSummary(): getting alert ${id}`); - const rule = (await this.get({ id, includeLegacyId: true })) as SanitizedAlertWithLegacyId; + const rule = (await this.get({ id, includeLegacyId: true })) as SanitizedRuleWithLegacyId; await this.authorization.ensureAuthorized({ ruleTypeId: rule.alertTypeId, @@ -612,7 +605,7 @@ export class RulesClient { per_page: perPage, total, saved_objects: data, - } = await this.unsecuredSavedObjectsClient.find({ + } = await this.unsecuredSavedObjectsClient.find({ ...options, sortField: mapSortField(options.sortField), filter: @@ -646,7 +639,7 @@ export class RulesClient { return this.getAlertFromRaw( id, attributes.alertTypeId, - fields ? (pick(attributes, fields) as RawAlert) : attributes, + fields ? (pick(attributes, fields) as RawRule) : attributes, references ); }); @@ -687,7 +680,7 @@ export class RulesClient { throw error; } const { filter: authorizationFilter } = authorizationTuple; - const resp = await this.unsecuredSavedObjectsClient.find({ + const resp = await this.unsecuredSavedObjectsClient.find({ ...options, filter: (authorizationFilter && filter @@ -776,11 +769,11 @@ export class RulesClient { private async deleteWithOCC({ id }: { id: string }) { let taskIdToRemove: string | undefined | null; let apiKeyToInvalidate: string | null = null; - let attributes: RawAlert; + let attributes: RawRule; try { const decryptedAlert = - await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { + await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); apiKeyToInvalidate = decryptedAlert.attributes.apiKey; @@ -792,7 +785,7 @@ export class RulesClient { `delete(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the scheduledTaskId using SOC - const alert = await this.unsecuredSavedObjectsClient.get('alert', id); + const alert = await this.unsecuredSavedObjectsClient.get('alert', id); taskIdToRemove = alert.attributes.scheduledTaskId; attributes = alert.attributes; } @@ -854,20 +847,23 @@ export class RulesClient { id, data, }: UpdateOptions): Promise> { - let alertSavedObject: SavedObject; + let alertSavedObject: SavedObject; try { - alertSavedObject = - await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { + alertSavedObject = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + 'alert', + id, + { namespace: this.namespace, - }); + } + ); } catch (e) { // We'll skip invalidating the API key since we failed to load the decrypted saved object this.logger.error( `update(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the object using SOC - alertSavedObject = await this.unsecuredSavedObjectsClient.get('alert', id); + alertSavedObject = await this.unsecuredSavedObjectsClient.get('alert', id); } try { @@ -934,15 +930,12 @@ export class RulesClient { private async updateAlert( { id, data }: UpdateOptions, - { attributes, version }: SavedObject + { attributes, version }: SavedObject ): Promise> { const ruleType = this.ruleTypeRegistry.get(attributes.alertTypeId); // Validate - const validatedAlertTypeParams = validateAlertTypeParams( - data.params, - ruleType.validate?.params - ); + const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params); await this.validateActions(ruleType, data.actions); // Validate intervals, if configured @@ -977,19 +970,19 @@ export class RulesClient { const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); - let updatedObject: SavedObject; + let updatedObject: SavedObject; const createAttributes = this.updateMeta({ ...attributes, ...data, ...apiKeyAttributes, - params: updatedParams as RawAlert['params'], + params: updatedParams as RawRule['params'], actions, notifyWhen, updatedBy: username, updatedAt: new Date().toISOString(), }); try { - updatedObject = await this.unsecuredSavedObjectsClient.create( + updatedObject = await this.unsecuredSavedObjectsClient.create( 'alert', createAttributes, { @@ -1020,7 +1013,7 @@ export class RulesClient { private apiKeyAsAlertAttributes( apiKey: CreateAPIKeyResult | null, username: string | null - ): Pick { + ): Pick { return apiKey && apiKey.apiKeysEnabled ? { apiKeyOwner: username, @@ -1042,12 +1035,12 @@ export class RulesClient { private async updateApiKeyWithOCC({ id }: { id: string }) { let apiKeyToInvalidate: string | null = null; - let attributes: RawAlert; + let attributes: RawRule; let version: string | undefined; try { const decryptedAlert = - await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { + await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); apiKeyToInvalidate = decryptedAlert.attributes.apiKey; @@ -1059,7 +1052,7 @@ export class RulesClient { `updateApiKey(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the attributes and version using SOC - const alert = await this.unsecuredSavedObjectsClient.get('alert', id); + const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; version = alert.version; } @@ -1146,12 +1139,12 @@ export class RulesClient { private async enableWithOCC({ id }: { id: string }) { let apiKeyToInvalidate: string | null = null; - let attributes: RawAlert; + let attributes: RawRule; let version: string | undefined; try { const decryptedAlert = - await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { + await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); apiKeyToInvalidate = decryptedAlert.attributes.apiKey; @@ -1163,7 +1156,7 @@ export class RulesClient { `enable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the attributes and version using SOC - const alert = await this.unsecuredSavedObjectsClient.get('alert', id); + const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; version = alert.version; } @@ -1265,12 +1258,12 @@ export class RulesClient { private async disableWithOCC({ id }: { id: string }) { let apiKeyToInvalidate: string | null = null; - let attributes: RawAlert; + let attributes: RawRule; let version: string | undefined; try { const decryptedAlert = - await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { + await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); apiKeyToInvalidate = decryptedAlert.attributes.apiKey; @@ -1282,7 +1275,7 @@ export class RulesClient { `disable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the attributes and version using SOC - const alert = await this.unsecuredSavedObjectsClient.get('alert', id); + const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; version = alert.version; } @@ -1403,7 +1396,7 @@ export class RulesClient { } private async muteAllWithOCC({ id }: { id: string }) { - const { attributes, version } = await this.unsecuredSavedObjectsClient.get( + const { attributes, version } = await this.unsecuredSavedObjectsClient.get( 'alert', id ); @@ -1465,7 +1458,7 @@ export class RulesClient { } private async unmuteAllWithOCC({ id }: { id: string }) { - const { attributes, version } = await this.unsecuredSavedObjectsClient.get( + const { attributes, version } = await this.unsecuredSavedObjectsClient.get( 'alert', id ); @@ -1633,7 +1626,7 @@ export class RulesClient { const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && mutedInstanceIds.includes(alertInstanceId)) { - await this.unsecuredSavedObjectsClient.update( + await this.unsecuredSavedObjectsClient.update( 'alert', alertId, this.updateMeta({ @@ -1691,7 +1684,7 @@ export class RulesClient { private injectReferencesIntoActions( alertId: string, - actions: RawAlert['actions'], + actions: RawRule['actions'], references: SavedObjectReference[] ) { return actions.map((action) => { @@ -1716,18 +1709,18 @@ export class RulesClient { private getAlertFromRaw( id: string, ruleTypeId: string, - rawAlert: RawAlert, + rawRule: RawRule, references: SavedObjectReference[] | undefined, includeLegacyId: boolean = false ): Alert | AlertWithLegacyId { const ruleType = this.ruleTypeRegistry.get(ruleTypeId); // In order to support the partial update API of Saved Objects we have to support - // partial updates of an Alert, but when we receive an actual RawAlert, it is safe + // partial updates of an Alert, but when we receive an actual RawRule, it is safe // to cast the result to an Alert const res = this.getPartialAlertFromRaw( id, ruleType, - rawAlert, + rawRule, references, includeLegacyId ); @@ -1741,7 +1734,7 @@ export class RulesClient { private getPartialAlertFromRaw( id: string, - ruleType: UntypedNormalizedAlertType, + ruleType: UntypedNormalizedRuleType, { createdAt, updatedAt, @@ -1753,15 +1746,15 @@ export class RulesClient { executionStatus, schedule, actions, - ...partialRawAlert - }: Partial, + ...partialRawRule + }: Partial, references: SavedObjectReference[] | undefined, includeLegacyId: boolean = false ): PartialAlert | PartialAlertWithLegacyId { const rule = { id, notifyWhen, - ...partialRawAlert, + ...partialRawRule, // we currently only support the Interval Schedule type // Once we support additional types, this type signature will likely change schedule: schedule as IntervalSchedule, @@ -1771,7 +1764,7 @@ export class RulesClient { ...(createdAt ? { createdAt: new Date(createdAt) } : {}), ...(scheduledTaskId ? { scheduledTaskId } : {}), ...(executionStatus - ? { executionStatus: alertExecutionStatusFromRaw(this.logger, id, executionStatus) } + ? { executionStatus: ruleExecutionStatusFromRaw(this.logger, id, executionStatus) } : {}), }; return includeLegacyId @@ -1780,7 +1773,7 @@ export class RulesClient { } private async validateActions( - alertType: UntypedNormalizedAlertType, + alertType: UntypedNormalizedRuleType, actions: NormalizedAlertAction[] ): Promise { if (actions.length === 0) { @@ -1831,11 +1824,11 @@ export class RulesClient { Params extends AlertTypeParams, ExtractedParams extends AlertTypeParams >( - ruleType: UntypedNormalizedAlertType, + ruleType: UntypedNormalizedRuleType, ruleActions: NormalizedAlertAction[], ruleParams: Params ): Promise<{ - actions: RawAlert['actions']; + actions: RawRule['actions']; params: ExtractedParams; references: SavedObjectReference[]; }> { @@ -1868,7 +1861,7 @@ export class RulesClient { ExtractedParams extends AlertTypeParams >( ruleId: string, - ruleType: UntypedNormalizedAlertType, + ruleType: UntypedNormalizedRuleType, ruleParams: SavedObjectAttributes | undefined, references: SavedObjectReference[] ): Params { @@ -1896,9 +1889,9 @@ export class RulesClient { private async denormalizeActions( alertActions: NormalizedAlertAction[] - ): Promise<{ actions: RawAlert['actions']; references: SavedObjectReference[] }> { + ): Promise<{ actions: RawRule['actions']; references: SavedObjectReference[] }> { const references: SavedObjectReference[] = []; - const actions: RawAlert['actions'] = []; + const actions: RawRule['actions'] = []; if (alertActions.length) { const actionsClient = await this.getActionsClient(); const actionIds = [...new Set(alertActions.map((alertAction) => alertAction.id))]; @@ -1953,7 +1946,7 @@ export class RulesClient { return truncate(`Alerting: ${alertTypeId}/${trim(alertName)}`, { length: 256 }); } - private updateMeta>(alertAttributes: T): T { + private updateMeta>(alertAttributes: T): T { if (alertAttributes.hasOwnProperty('apiKey') || alertAttributes.hasOwnProperty('apiKeyOwner')) { alertAttributes.meta = alertAttributes.meta ?? {}; alertAttributes.meta.versionApiKeyLastmodified = this.kibanaVersion; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index 86bb97fec6ed8..7a7ab035aa391 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -19,7 +19,7 @@ import { eventLogClientMock } from '../../../../event_log/server/mocks'; import { QueryEventsBySavedObjectResult } from '../../../../event_log/server'; import { SavedObject } from 'kibana/server'; import { EventsFactory } from '../../lib/alert_summary_from_event_log.test'; -import { RawAlert } from '../../types'; +import { RawRule } from '../../types'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); @@ -64,7 +64,7 @@ const AlertSummaryFindEventsResult: QueryEventsBySavedObjectResult = { const RuleIntervalSeconds = 1; -const BaseRuleSavedObject: SavedObject = { +const BaseRuleSavedObject: SavedObject = { id: '1', type: 'alert', attributes: { @@ -96,7 +96,7 @@ const BaseRuleSavedObject: SavedObject = { references: [], }; -function getRuleSavedObject(attributes: Partial = {}): SavedObject { +function getRuleSavedObject(attributes: Partial = {}): SavedObject { return { ...BaseRuleSavedObject, attributes: { ...BaseRuleSavedObject.attributes, ...attributes }, diff --git a/x-pack/plugins/alerting/server/saved_objects/geo_containment/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/geo_containment/migrations.ts index 113b4cf796d2f..50e6979d6ee72 100644 --- a/x-pack/plugins/alerting/server/saved_objects/geo_containment/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/geo_containment/migrations.ts @@ -12,7 +12,7 @@ import { } from 'kibana/server'; import { AlertTypeParams } from '../../index'; import { Query } from '../../../../../../src/plugins/data/common/query'; -import { RawAlert } from '../../types'; +import { RawRule } from '../../types'; // These definitions are dupes of the SO-types in stack_alerts/geo_containment // There are not exported to avoid deep imports from stack_alerts plugins into here @@ -69,8 +69,8 @@ export function extractEntityAndBoundaryReferences(params: GeoContainmentParams) } export function extractRefsFromGeoContainmentAlert( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { if (doc.attributes.alertTypeId !== GEO_CONTAINMENT_ID) { return doc; } diff --git a/x-pack/plugins/alerting/server/saved_objects/get_import_warnings.test.ts b/x-pack/plugins/alerting/server/saved_objects/get_import_warnings.test.ts index 18c26336721fc..0bf2db556c2dc 100644 --- a/x-pack/plugins/alerting/server/saved_objects/get_import_warnings.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/get_import_warnings.test.ts @@ -6,7 +6,7 @@ */ import { SavedObject } from 'kibana/server'; -import { RawAlert } from '../types'; +import { RawRule } from '../types'; import { getImportWarnings } from './get_import_warnings'; describe('getImportWarnings', () => { @@ -71,13 +71,13 @@ describe('getImportWarnings', () => { references: [], }, ]; - const warnings = getImportWarnings(savedObjectRules as unknown as Array>); + const warnings = getImportWarnings(savedObjectRules as unknown as Array>); expect(warnings[0].message).toBe('2 rules must be enabled after the import.'); }); it('return no warning messages if no rules were imported', () => { - const savedObjectRules = [] as Array>; - const warnings = getImportWarnings(savedObjectRules as unknown as Array>); + const savedObjectRules = [] as Array>; + const warnings = getImportWarnings(savedObjectRules as unknown as Array>); expect(warnings.length).toBe(0); }); }); diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index eb561b3c285f8..d53635ec4f05d 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -16,7 +16,7 @@ import mappings from './mappings.json'; import { getMigrations } from './migrations'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; import { transformRulesForExport } from './transform_rule_for_export'; -import { RawAlert } from '../types'; +import { RawRule } from '../types'; import { getImportWarnings } from './get_import_warnings'; import { isRuleExportable } from './is_rule_exportable'; import { RuleTypeRegistry } from '../rule_type_registry'; @@ -60,7 +60,7 @@ export function setupSavedObjects( management: { displayName: 'rule', importableAndExportable: true, - getTitle(ruleSavedObject: SavedObject) { + getTitle(ruleSavedObject: SavedObject) { return `Rule: [${ruleSavedObject.attributes.name}]`; }, onImport(ruleSavedObjects) { @@ -68,13 +68,13 @@ export function setupSavedObjects( warnings: getImportWarnings(ruleSavedObjects), }; }, - onExport( + onExport( context: SavedObjectsExportTransformContext, - objects: Array> + objects: Array> ) { return transformRulesForExport(objects); }, - isExportable(ruleSavedObject: SavedObject) { + isExportable(ruleSavedObject: SavedObject) { return isRuleExportable(ruleSavedObject, ruleTypeRegistry, logger); }, }, diff --git a/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.ts b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.ts index 9c4e4f2b4d409..31ada226cacfd 100644 --- a/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.ts +++ b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.ts @@ -6,7 +6,7 @@ */ import { Logger, SavedObject } from 'kibana/server'; -import { RawAlert } from '../types'; +import { RawRule } from '../types'; import { RuleTypeRegistry } from '../rule_type_registry'; export function isRuleExportable( @@ -14,7 +14,7 @@ export function isRuleExportable( ruleTypeRegistry: RuleTypeRegistry, logger: Logger ): boolean { - const ruleSO = rule as SavedObject; + const ruleSO = rule as SavedObject; try { const ruleType = ruleTypeRegistry.get(ruleSO.attributes.alertTypeId); if (!ruleType.isExportable) { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 481edb07cedb9..08312d0be0419 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -7,7 +7,7 @@ import uuid from 'uuid'; import { getMigrations, isAnyActionSupportIncidents } from './migrations'; -import { RawAlert } from '../types'; +import { RawRule } from '../types'; import { SavedObjectUnsanitizedDoc } from 'kibana/server'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { migrationMocks } from 'src/core/server/mocks'; @@ -512,7 +512,7 @@ describe('successful migrations', () => { (actionTypeId) => { const doc = { attributes: { actions: [{ actionTypeId }, { actionTypeId: '.server-log' }] }, - } as SavedObjectUnsanitizedDoc; + } as SavedObjectUnsanitizedDoc; expect(isAnyActionSupportIncidents(doc)).toBe(true); } ); @@ -520,7 +520,7 @@ describe('successful migrations', () => { test('isAnyActionSupportIncidents should return false when there is no connector that supports incidents', () => { const doc = { attributes: { actions: [{ actionTypeId: '.server-log' }] }, - } as SavedObjectUnsanitizedDoc; + } as SavedObjectUnsanitizedDoc; expect(isAnyActionSupportIncidents(doc)).toBe(false); }); @@ -2254,7 +2254,7 @@ function getUpdatedAt(): string { function getMockData( overwrites: Record = {}, withSavedObjectUpdatedAt: boolean = false -): SavedObjectUnsanitizedDoc> { +): SavedObjectUnsanitizedDoc> { return { attributes: { enabled: true, diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 201c78ed2340d..6736fd3573adb 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -17,7 +17,7 @@ import { SavedObjectAttribute, SavedObjectReference, } from '../../../../../src/core/server'; -import { RawAlert, RawAlertAction } from '../types'; +import { RawRule, RawAlertAction } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; import type { IsMigrationNeededPredicate } from '../../../encrypted_saved_objects/server'; import { extractRefsFromGeoContainmentAlert } from './geo_containment/migrations'; @@ -28,19 +28,19 @@ export const LEGACY_LAST_MODIFIED_VERSION = 'pre-7.10.0'; export const FILEBEAT_7X_INDICATOR_PATH = 'threatintel.indicator'; interface AlertLogMeta extends LogMeta { - migrations: { alertDocument: SavedObjectUnsanitizedDoc }; + migrations: { alertDocument: SavedObjectUnsanitizedDoc }; } type AlertMigration = ( - doc: SavedObjectUnsanitizedDoc -) => SavedObjectUnsanitizedDoc; + doc: SavedObjectUnsanitizedDoc +) => SavedObjectUnsanitizedDoc; function createEsoMigration( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, - isMigrationNeededPredicate: IsMigrationNeededPredicate, + isMigrationNeededPredicate: IsMigrationNeededPredicate, migrationFunc: AlertMigration ) { - return encryptedSavedObjects.createMigration({ + return encryptedSavedObjects.createMigration({ isMigrationNeededPredicate, migration: migrationFunc, shouldMigrateIfDecryptionFails: true, // shouldMigrateIfDecryptionFails flag that applies the migration to undecrypted document if decryption fails @@ -49,13 +49,13 @@ function createEsoMigration( const SUPPORT_INCIDENTS_ACTION_TYPES = ['.servicenow', '.jira', '.resilient']; -export const isAnyActionSupportIncidents = (doc: SavedObjectUnsanitizedDoc): boolean => +export const isAnyActionSupportIncidents = (doc: SavedObjectUnsanitizedDoc): boolean => doc.attributes.actions.some((action) => SUPPORT_INCIDENTS_ACTION_TYPES.includes(action.actionTypeId) ); // Deprecated in 8.0 -export const isSiemSignalsRuleType = (doc: SavedObjectUnsanitizedDoc): boolean => +export const isSiemSignalsRuleType = (doc: SavedObjectUnsanitizedDoc): boolean => doc.attributes.alertTypeId === 'siem.signals'; /** @@ -66,7 +66,7 @@ export const isSiemSignalsRuleType = (doc: SavedObjectUnsanitizedDoc): * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ export const isSecuritySolutionLegacyNotification = ( - doc: SavedObjectUnsanitizedDoc + doc: SavedObjectUnsanitizedDoc ): boolean => doc.attributes.alertTypeId === 'siem.notifications'; export function getMigrations( @@ -76,7 +76,7 @@ export function getMigrations( const migrationWhenRBACWasIntroduced = createEsoMigration( encryptedSavedObjects, // migrate all documents in 7.10 in order to add the "meta" RBAC field - (doc): doc is SavedObjectUnsanitizedDoc => true, + (doc): doc is SavedObjectUnsanitizedDoc => true, pipeMigrations( markAsLegacyAndChangeConsumer, setAlertIdAsDefaultDedupkeyOnPagerDutyActions, @@ -87,37 +87,37 @@ export function getMigrations( const migrationAlertUpdatedAtAndNotifyWhen = createEsoMigration( encryptedSavedObjects, // migrate all documents in 7.11 in order to add the "updatedAt" and "notifyWhen" fields - (doc): doc is SavedObjectUnsanitizedDoc => true, + (doc): doc is SavedObjectUnsanitizedDoc => true, pipeMigrations(setAlertUpdatedAtDate, setNotifyWhen) ); const migrationActions7112 = createEsoMigration( encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => isAnyActionSupportIncidents(doc), + (doc): doc is SavedObjectUnsanitizedDoc => isAnyActionSupportIncidents(doc), pipeMigrations(restructureConnectorsThatSupportIncident) ); const migrationSecurityRules713 = createEsoMigration( encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => isSiemSignalsRuleType(doc), + (doc): doc is SavedObjectUnsanitizedDoc => isSiemSignalsRuleType(doc), pipeMigrations(removeNullsFromSecurityRules) ); const migrationSecurityRules714 = createEsoMigration( encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => isSiemSignalsRuleType(doc), + (doc): doc is SavedObjectUnsanitizedDoc => isSiemSignalsRuleType(doc), pipeMigrations(removeNullAuthorFromSecurityRules) ); const migrationSecurityRules715 = createEsoMigration( encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => isSiemSignalsRuleType(doc), + (doc): doc is SavedObjectUnsanitizedDoc => isSiemSignalsRuleType(doc), pipeMigrations(addExceptionListsToReferences) ); const migrateRules716 = createEsoMigration( encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => true, + (doc): doc is SavedObjectUnsanitizedDoc => true, pipeMigrations( setLegacyId, getRemovePreconfiguredConnectorsFromReferencesFn(isPreconfigured), @@ -128,7 +128,7 @@ export function getMigrations( const migrationRules800 = createEsoMigration( encryptedSavedObjects, - (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, pipeMigrations( addThreatIndicatorPathToThreatMatchRules, addRACRuleTypes, @@ -149,10 +149,10 @@ export function getMigrations( } function executeMigrationWithErrorHandling( - migrationFunc: SavedObjectMigrationFn, + migrationFunc: SavedObjectMigrationFn, version: string ) { - return (doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => { + return (doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => { try { return migrationFunc(doc, context); } catch (ex) { @@ -170,8 +170,8 @@ function executeMigrationWithErrorHandling( } const setAlertUpdatedAtDate = ( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc => { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { const updatedAt = doc.updated_at || doc.attributes.createdAt; return { ...doc, @@ -183,8 +183,8 @@ const setAlertUpdatedAtDate = ( }; const setNotifyWhen = ( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc => { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { const notifyWhen = doc.attributes.throttle ? 'onThrottleInterval' : 'onActiveAlert'; return { ...doc, @@ -204,8 +204,8 @@ const consumersToChange: Map = new Map( ); function markAsLegacyAndChangeConsumer( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const { attributes: { consumer }, } = doc; @@ -223,8 +223,8 @@ function markAsLegacyAndChangeConsumer( } function setAlertIdAsDefaultDedupkeyOnPagerDutyActions( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const { attributes } = doc; return { ...doc, @@ -251,8 +251,8 @@ function setAlertIdAsDefaultDedupkeyOnPagerDutyActions( } function initializeExecutionStatus( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const { attributes } = doc; return { ...doc, @@ -277,8 +277,8 @@ function isEmptyObject(obj: {}) { } function restructureConnectorsThatSupportIncident( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const { actions } = doc.attributes; const newActions = actions.reduce((acc, action) => { if ( @@ -416,8 +416,8 @@ function convertNullToUndefined(attribute: SavedObjectAttribute) { } function removeNullsFromSecurityRules( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const { attributes: { params }, } = doc; @@ -490,8 +490,8 @@ function removeNullsFromSecurityRules( * @returns The document with the author field fleshed in. */ function removeNullAuthorFromSecurityRules( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const { attributes: { params }, } = doc; @@ -519,8 +519,8 @@ function removeNullAuthorFromSecurityRules( * @returns The document migrated with saved object references */ function addExceptionListsToReferences( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const { attributes: { params: { exceptionsList }, @@ -610,8 +610,8 @@ function removeMalformedExceptionsList( * @returns The document migrated with saved object references */ function addRuleIdsToLegacyNotificationReferences( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const { attributes: { params: { ruleAlertId }, @@ -641,9 +641,7 @@ function addRuleIdsToLegacyNotificationReferences( } } -function setLegacyId( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { +function setLegacyId(doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc { const { id } = doc; return { ...doc, @@ -655,8 +653,8 @@ function setLegacyId( } function addRACRuleTypes( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { const ruleType = doc.attributes.params.type; return isSiemSignalsRuleType(doc) && isRuleType(ruleType) ? { @@ -674,8 +672,8 @@ function addRACRuleTypes( } function addThreatIndicatorPathToThreatMatchRules( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { return isSiemSignalsRuleType(doc) && doc.attributes.params?.type === 'threat_match' && !doc.attributes.params.threatIndicatorPath @@ -695,15 +693,15 @@ function addThreatIndicatorPathToThreatMatchRules( function getRemovePreconfiguredConnectorsFromReferencesFn( isPreconfigured: (connectorId: string) => boolean ) { - return (doc: SavedObjectUnsanitizedDoc) => { + return (doc: SavedObjectUnsanitizedDoc) => { return removePreconfiguredConnectorsFromReferences(doc, isPreconfigured); }; } function removePreconfiguredConnectorsFromReferences( - doc: SavedObjectUnsanitizedDoc, + doc: SavedObjectUnsanitizedDoc, isPreconfigured: (connectorId: string) => boolean -): SavedObjectUnsanitizedDoc { +): SavedObjectUnsanitizedDoc { const { attributes: { actions }, references, @@ -719,7 +717,7 @@ function removePreconfiguredConnectorsFromReferences( ); const updatedConnectorReferences: SavedObjectReference[] = []; - const updatedActions: RawAlert['actions'] = []; + const updatedActions: RawRule['actions'] = []; // For each connector reference, check if connector is preconfigured // If yes, we need to remove from the references array and update @@ -758,8 +756,8 @@ function removePreconfiguredConnectorsFromReferences( // This fixes an issue whereby metrics.alert.inventory.threshold rules had the // group for actions incorrectly spelt as metrics.invenotry_threshold.fired vs metrics.inventory_threshold.fired function fixInventoryThresholdGroupId( - doc: SavedObjectUnsanitizedDoc -): SavedObjectUnsanitizedDoc { + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { if (doc.attributes.alertTypeId === 'metrics.alert.inventory.threshold') { const { attributes: { actions }, @@ -805,6 +803,6 @@ function getCorrespondingAction( } function pipeMigrations(...migrations: AlertMigration[]): AlertMigration { - return (doc: SavedObjectUnsanitizedDoc) => + return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); } diff --git a/x-pack/plugins/alerting/server/saved_objects/partially_update_alert.ts b/x-pack/plugins/alerting/server/saved_objects/partially_update_alert.ts index bb211c87867c0..76303722fc876 100644 --- a/x-pack/plugins/alerting/server/saved_objects/partially_update_alert.ts +++ b/x-pack/plugins/alerting/server/saved_objects/partially_update_alert.ts @@ -6,7 +6,7 @@ */ import { pick } from 'lodash'; -import { RawAlert } from '../types'; +import { RawRule } from '../types'; import { SavedObjectsClient, @@ -17,7 +17,7 @@ import { import { AlertAttributesExcludedFromAAD, AlertAttributesExcludedFromAADType } from './index'; export type PartiallyUpdateableAlertAttributes = Partial< - Pick + Pick >; export interface PartiallyUpdateAlertSavedObjectOptions { @@ -40,7 +40,7 @@ export async function partiallyUpdateAlert( ): Promise { // ensure we only have the valid attributes excluded from AAD const attributeUpdates = pick(attributes, AlertAttributesExcludedFromAAD); - const updateOptions: SavedObjectsUpdateOptions = pick( + const updateOptions: SavedObjectsUpdateOptions = pick( options, 'namespace', 'version', @@ -48,7 +48,7 @@ export async function partiallyUpdateAlert( ); try { - await savedObjectsClient.update('alert', id, attributeUpdates, updateOptions); + await savedObjectsClient.update('alert', id, attributeUpdates, updateOptions); } catch (err) { if (options?.ignore404 && SavedObjectsErrorHelpers.isNotFoundError(err)) { return; diff --git a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts index 8236c4455478c..a5befc94a340a 100644 --- a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts @@ -6,8 +6,8 @@ */ import { transformRulesForExport } from './transform_rule_for_export'; -jest.mock('../lib/alert_execution_status', () => ({ - getAlertExecutionStatusPending: () => ({ +jest.mock('../lib/rule_execution_status', () => ({ + getRuleExecutionStatusPending: () => ({ status: 'pending', lastExecutionDate: '2020-08-20T19:23:38Z', error: null, diff --git a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts index 97fd226b49e8e..5b89f6394e3c5 100644 --- a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts +++ b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts @@ -6,18 +6,18 @@ */ import { SavedObject } from 'kibana/server'; -import { getAlertExecutionStatusPending } from '../lib/alert_execution_status'; -import { RawAlert } from '../types'; +import { getRuleExecutionStatusPending } from '../lib/rule_execution_status'; +import { RawRule } from '../types'; -export function transformRulesForExport(rules: SavedObject[]): Array> { +export function transformRulesForExport(rules: SavedObject[]): Array> { const exportDate = new Date().toISOString(); - return rules.map((rule) => transformRuleForExport(rule as SavedObject, exportDate)); + return rules.map((rule) => transformRuleForExport(rule as SavedObject, exportDate)); } function transformRuleForExport( - rule: SavedObject, + rule: SavedObject, exportDate: string -): SavedObject { +): SavedObject { return { ...rule, attributes: { @@ -27,7 +27,7 @@ function transformRuleForExport( apiKey: null, apiKeyOwner: null, scheduledTaskId: null, - executionStatus: getAlertExecutionStatusPending(exportDate), + executionStatus: getRuleExecutionStatusPending(exportDate), }, }; } diff --git a/x-pack/plugins/alerting/server/task_runner/alert_task_instance.ts b/x-pack/plugins/alerting/server/task_runner/alert_task_instance.ts index 1ea9a473b914a..a6bb6a68ceae8 100644 --- a/x-pack/plugins/alerting/server/task_runner/alert_task_instance.ts +++ b/x-pack/plugins/alerting/server/task_runner/alert_task_instance.ts @@ -11,16 +11,16 @@ import { fold } from 'fp-ts/lib/Either'; import { ConcreteTaskInstance } from '../../../task_manager/server'; import { SanitizedAlert, - AlertTaskState, - alertParamsSchema, - alertStateSchema, - AlertTaskParams, + RuleTaskState, + ruleParamsSchema, + ruleStateSchema, + RuleTaskParams, AlertTypeParams, } from '../../common'; export interface AlertTaskInstance extends ConcreteTaskInstance { - state: AlertTaskState; - params: AlertTaskParams; + state: RuleTaskState; + params: RuleTaskParams; } const enumerateErrorFields = (e: t.Errors) => @@ -33,7 +33,7 @@ export function taskInstanceToAlertTaskInstance( return { ...taskInstance, params: pipe( - alertParamsSchema.decode(taskInstance.params), + ruleParamsSchema.decode(taskInstance.params), fold((e: t.Errors) => { throw new Error( `Task "${taskInstance.id}" ${ @@ -43,7 +43,7 @@ export function taskInstanceToAlertTaskInstance( }, t.identity) ), state: pipe( - alertStateSchema.decode(taskInstance.state), + ruleStateSchema.decode(taskInstance.state), fold((e: t.Errors) => { throw new Error( `Task "${taskInstance.id}" ${ diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index fc5c5cf8897f0..69b094585d703 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -16,7 +16,7 @@ import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { KibanaRequest } from 'kibana/server'; import { asSavedObjectExecutionSource } from '../../../actions/server'; import { InjectActionParamsOpts } from './inject_action_params'; -import { NormalizedAlertType } from '../rule_type_registry'; +import { NormalizedRuleType } from '../rule_type_registry'; import { AlertTypeParams, AlertTypeState, @@ -28,7 +28,7 @@ jest.mock('./inject_action_params', () => ({ injectActionParams: jest.fn(), })); -const alertType: NormalizedAlertType< +const ruleType: NormalizedRuleType< AlertTypeParams, AlertTypeParams, AlertTypeState, @@ -71,12 +71,12 @@ const createExecutionHandlerParams: jest.Mocked< > = { actionsPlugin: mockActionsPlugin, spaceId: 'test1', - alertId: '1', - alertName: 'name-of-alert', + ruleId: '1', + ruleName: 'name-of-alert', tags: ['tag-A', 'tag-B'], apiKey: 'MTIzOmFiYw==', kibanaBaseUrl: 'http://localhost:5601', - alertType, + ruleType, logger: loggingSystemMock.create().get(), eventLogger: mockEventLogger, actions: [ @@ -93,13 +93,13 @@ const createExecutionHandlerParams: jest.Mocked< }, ], request: {} as KibanaRequest, - alertParams: { + ruleParams: { foo: true, contextVal: 'My other {{context.value}} goes here', stateVal: 'My other {{state.value}} goes here', }, supportsEphemeralTasks: false, - maxEphemeralActionsPerAlert: 10, + maxEphemeralActionsPerRule: 10, }; beforeEach(() => { @@ -123,7 +123,7 @@ test('enqueues execution per selected action', async () => { actionGroup: 'default', state: {}, context: {}, - alertInstanceId: '2', + alertId: '2', }); expect(mockActionsPlugin.getActionsClientWithRequest).toHaveBeenCalledWith( createExecutionHandlerParams.request @@ -244,7 +244,7 @@ test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => actionGroup: 'default', state: {}, context: {}, - alertInstanceId: '2', + alertId: '2', }); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution).toHaveBeenCalledWith({ @@ -296,7 +296,7 @@ test('trow error error message when action type is disabled', async () => { actionGroup: 'default', state: {}, context: {}, - alertInstanceId: '2', + alertId: '2', }); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); @@ -310,7 +310,7 @@ test('trow error error message when action type is disabled', async () => { actionGroup: 'default', state: {}, context: {}, - alertInstanceId: '2', + alertId: '2', }); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); }); @@ -321,7 +321,7 @@ test('limits actionsPlugin.execute per action group', async () => { actionGroup: 'other-group', state: {}, context: {}, - alertInstanceId: '2', + alertId: '2', }); expect(actionsClient.enqueueExecution).not.toHaveBeenCalled(); }); @@ -332,7 +332,7 @@ test('context attribute gets parameterized', async () => { actionGroup: 'default', context: { value: 'context-val' }, state: {}, - alertInstanceId: '2', + alertId: '2', }); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` @@ -373,7 +373,7 @@ test('state attribute gets parameterized', async () => { actionGroup: 'default', context: {}, state: { value: 'state-val' }, - alertInstanceId: '2', + alertId: '2', }); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` @@ -408,7 +408,7 @@ test('state attribute gets parameterized', async () => { `); }); -test(`logs an error when action group isn't part of actionGroups available for the alertType`, async () => { +test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { const executionHandler = createExecutionHandler(createExecutionHandlerParams); const result = await executionHandler({ // we have to trick the compiler as this is an invalid type and this test checks whether we @@ -416,10 +416,10 @@ test(`logs an error when action group isn't part of actionGroups available for t actionGroup: 'invalid-group' as 'default' | 'other-group', context: {}, state: {}, - alertInstanceId: '2', + alertId: '2', }); expect(result).toBeUndefined(); expect(createExecutionHandlerParams.logger.error).toHaveBeenCalledWith( - 'Invalid action group "invalid-group" for alert "test".' + 'Invalid action group "invalid-group" for rule "test".' ); }); diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index d93d8cd6d1312..112cb949e3ad7 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -19,9 +19,9 @@ import { AlertTypeState, AlertInstanceState, AlertInstanceContext, - RawAlert, + RawRule, } from '../types'; -import { NormalizedAlertType, UntypedNormalizedAlertType } from '../rule_type_registry'; +import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { isEphemeralTaskRejectedDueToCapacityError } from '../../../task_manager/server'; import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; @@ -34,15 +34,15 @@ export interface CreateExecutionHandlerOptions< ActionGroupIds extends string, RecoveryActionGroupId extends string > { - alertId: string; - alertName: string; + ruleId: string; + ruleName: string; tags?: string[]; actionsPlugin: ActionsPluginStartContract; actions: AlertAction[]; spaceId: string; - apiKey: RawAlert['apiKey']; + apiKey: RawRule['apiKey']; kibanaBaseUrl: string | undefined; - alertType: NormalizedAlertType< + ruleType: NormalizedRuleType< Params, ExtractedParams, State, @@ -54,15 +54,15 @@ export interface CreateExecutionHandlerOptions< logger: Logger; eventLogger: IEventLogger; request: KibanaRequest; - alertParams: AlertTypeParams; + ruleParams: AlertTypeParams; supportsEphemeralTasks: boolean; - maxEphemeralActionsPerAlert: number; + maxEphemeralActionsPerRule: number; } interface ExecutionHandlerOptions { actionGroup: ActionGroupIds; actionSubgroup?: string; - alertInstanceId: string; + alertId: string; context: AlertInstanceContext; state: AlertInstanceState; } @@ -81,20 +81,20 @@ export function createExecutionHandler< RecoveryActionGroupId extends string >({ logger, - alertId, - alertName, + ruleId, + ruleName, tags, actionsPlugin, - actions: alertActions, + actions: ruleActions, spaceId, apiKey, - alertType, + ruleType, kibanaBaseUrl, eventLogger, request, - alertParams, + ruleParams, supportsEphemeralTasks, - maxEphemeralActionsPerAlert, + maxEphemeralActionsPerRule, }: CreateExecutionHandlerOptions< Params, ExtractedParams, @@ -104,66 +104,66 @@ export function createExecutionHandler< ActionGroupIds, RecoveryActionGroupId >): ExecutionHandler { - const alertTypeActionGroups = new Map( - alertType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) + const ruleTypeActionGroups = new Map( + ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) ); return async ({ actionGroup, actionSubgroup, context, state, - alertInstanceId, + alertId, }: ExecutionHandlerOptions) => { - if (!alertTypeActionGroups.has(actionGroup)) { - logger.error(`Invalid action group "${actionGroup}" for alert "${alertType.id}".`); + if (!ruleTypeActionGroups.has(actionGroup)) { + logger.error(`Invalid action group "${actionGroup}" for rule "${ruleType.id}".`); return; } - const actions = alertActions + const actions = ruleActions .filter(({ group }) => group === actionGroup) .map((action) => { return { ...action, params: transformActionParams({ actionsPlugin, - alertId, - alertType: alertType.id, + alertId: ruleId, + alertType: ruleType.id, actionTypeId: action.actionTypeId, - alertName, + alertName: ruleName, spaceId, tags, - alertInstanceId, + alertInstanceId: alertId, alertActionGroup: actionGroup, - alertActionGroupName: alertTypeActionGroups.get(actionGroup)!, + alertActionGroupName: ruleTypeActionGroups.get(actionGroup)!, alertActionSubgroup: actionSubgroup, context, actionParams: action.params, actionId: action.id, state, kibanaBaseUrl, - alertParams, + alertParams: ruleParams, }), }; }) .map((action) => ({ ...action, params: injectActionParams({ - ruleId: alertId, + ruleId, spaceId, actionParams: action.params, actionTypeId: action.actionTypeId, }), })); - const alertLabel = `${alertType.id}:${alertId}: '${alertName}'`; + const ruleLabel = `${ruleType.id}:${ruleId}: '${ruleName}'`; const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); - let ephemeralActionsToSchedule = maxEphemeralActionsPerAlert; + let ephemeralActionsToSchedule = maxEphemeralActionsPerRule; for (const action of actions) { if ( !actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { notifyUsage: true }) ) { logger.warn( - `Alert "${alertId}" skipped scheduling action "${action.id}" because it is disabled` + `Rule "${ruleId}" skipped scheduling action "${action.id}" because it is disabled` ); continue; } @@ -176,15 +176,15 @@ export function createExecutionHandler< spaceId, apiKey: apiKey ?? null, source: asSavedObjectExecutionSource({ - id: alertId, + id: ruleId, type: 'alert', }), relatedSavedObjects: [ { - id: alertId, + id: ruleId, type: 'alert', namespace: namespace.namespace, - typeId: alertType.id, + typeId: ruleType.id, }, ], }; @@ -203,18 +203,18 @@ export function createExecutionHandler< } const event = createAlertEventLogRecordObject({ - ruleId: alertId, - ruleType: alertType as UntypedNormalizedAlertType, + ruleId, + ruleType: ruleType as UntypedNormalizedRuleType, action: EVENT_LOG_ACTIONS.executeAction, - instanceId: alertInstanceId, + instanceId: alertId, group: actionGroup, subgroup: actionSubgroup, - ruleName: alertName, + ruleName, savedObjects: [ { type: 'alert', - id: alertId, - typeId: alertType.id, + id: ruleId, + typeId: ruleType.id, relation: SAVED_OBJECT_REL_PRIMARY, }, { @@ -224,7 +224,7 @@ export function createExecutionHandler< }, ], ...namespace, - message: `alert: ${alertLabel} instanceId: '${alertInstanceId}' scheduled ${ + message: `alert: ${ruleLabel} instanceId: '${alertId}' scheduled ${ actionSubgroup ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` : `actionGroup: '${actionGroup}'` diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index d370a278e0a5c..eb5529a9db853 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -37,13 +37,13 @@ import { IEventLogger } from '../../../event_log/server'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { Alert, RecoveredActionGroup } from '../../common'; import { omit } from 'lodash'; -import { UntypedNormalizedAlertType } from '../rule_type_registry'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; import { ExecuteOptions } from '../../../actions/server/create_execute_function'; -const alertType: jest.Mocked = { +const ruleType: jest.Mocked = { id: 'test', - name: 'My test alert', + name: 'My test rule', actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', @@ -107,7 +107,7 @@ describe('Task Runner', () => { ruleTypeRegistry, kibanaBaseUrl: 'https://localhost:5601', supportsEphemeralTasks: false, - maxEphemeralActionsPerAlert: 10, + maxEphemeralActionsPerRule: 10, cancelAlertsOnRuleTimeout: true, }; @@ -133,7 +133,7 @@ describe('Task Runner', () => { const mockDate = new Date('2019-02-12T21:01:22.479Z'); - const mockedAlertTypeSavedObject: Alert = { + const mockedRuleTypeSavedObject: Alert = { id: '1', consumer: 'bar', createdAt: mockDate, @@ -142,14 +142,14 @@ describe('Task Runner', () => { muteAll: false, notifyWhen: 'onActiveAlert', enabled: true, - alertTypeId: alertType.id, + alertTypeId: ruleType.id, apiKey: '', apiKeyOwner: 'elastic', schedule: { interval: '10s' }, - name: 'alert-name', - tags: ['alert-', '-tags'], - createdBy: 'alert-creator', - updatedBy: 'alert-updater', + name: 'rule-name', + tags: ['rule-', '-tags'], + createdBy: 'rule-creator', + updatedBy: 'rule-updater', mutedInstanceIds: [], params: { bar: true, @@ -188,7 +188,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation( (actionTypeId, actionId, params) => params ); - ruleTypeRegistry.get.mockReturnValue(alertType); + ruleTypeRegistry.get.mockReturnValue(ruleType); taskRunnerFactoryInitializerParams.executionContext.withContext.mockImplementation((ctx, fn) => fn() ); @@ -196,7 +196,7 @@ describe('Task Runner', () => { test('successfully executes the task', async () => { const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -206,7 +206,7 @@ describe('Task Runner', () => { }, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -229,8 +229,8 @@ describe('Task Runner', () => { }, } `); - expect(alertType.executor).toHaveBeenCalledTimes(1); - const call = alertType.executor.mock.calls[0][0]; + expect(ruleType.executor).toHaveBeenCalledTimes(1); + const call = ruleType.executor.mock.calls[0][0]; expect(call.params).toMatchInlineSnapshot(` Object { "bar": true, @@ -239,13 +239,13 @@ describe('Task Runner', () => { expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); expect(call.previousStartedAt).toMatchInlineSnapshot(`1969-12-31T23:55:00.000Z`); expect(call.state).toMatchInlineSnapshot(`Object {}`); - expect(call.name).toBe('alert-name'); - expect(call.tags).toEqual(['alert-', '-tags']); - expect(call.createdBy).toBe('alert-creator'); - expect(call.updatedBy).toBe('alert-updater'); + expect(call.name).toBe('rule-name'); + expect(call.tags).toEqual(['rule-', '-tags']); + expect(call.createdBy).toBe('rule-creator'); + expect(call.updatedBy).toBe('rule-updater'); expect(call.rule).not.toBe(null); - expect(call.rule.name).toBe('alert-name'); - expect(call.rule.tags).toEqual(['alert-', '-tags']); + expect(call.rule.name).toBe('rule-name'); + expect(call.rule.tags).toEqual(['rule-', '-tags']); expect(call.rule.consumer).toBe('bar'); expect(call.rule.enabled).toBe(true); expect(call.rule.schedule).toMatchInlineSnapshot(` @@ -253,15 +253,15 @@ describe('Task Runner', () => { "interval": "10s", } `); - expect(call.rule.createdBy).toBe('alert-creator'); - expect(call.rule.updatedBy).toBe('alert-updater'); + expect(call.rule.createdBy).toBe('rule-creator'); + expect(call.rule.updatedBy).toBe('rule-updater'); expect(call.rule.createdAt).toBe(mockDate); expect(call.rule.updatedAt).toBe(mockDate); expect(call.rule.notifyWhen).toBe('onActiveAlert'); expect(call.rule.throttle).toBe(null); expect(call.rule.producer).toBe('alerts'); expect(call.rule.ruleTypeId).toBe('test'); - expect(call.rule.ruleTypeName).toBe('My test alert'); + expect(call.rule.ruleTypeName).toBe('My test rule'); expect(call.rule.actions).toMatchInlineSnapshot(` Array [ Object { @@ -288,10 +288,10 @@ describe('Task Runner', () => { const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(3); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' + 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' ); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; @@ -299,7 +299,6 @@ describe('Task Runner', () => { expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent.mock.calls[0][0]).toMatchInlineSnapshot(` Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -322,7 +321,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -354,14 +353,14 @@ describe('Task Runner', () => { id: '1', name: 'execute test', type: 'alert', - description: 'execute [test] with name [alert-name] in [default] namespace', + description: 'execute [test] with name [rule-name] in [default] namespace', }, expect.any(Function) ); }); testAgainstEphemeralSupport( - 'actionsPlugin.execute is called per alert instance that is scheduled', + 'actionsPlugin.execute is called per alert alert that is scheduled', ( customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, enqueueFunction: (options: ExecuteOptions) => Promise @@ -374,7 +373,7 @@ describe('Task Runner', () => { true ); actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -390,11 +389,11 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, customTaskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -436,21 +435,20 @@ describe('Task Runner', () => { const logger = customTaskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(4); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, - `alert test:1: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:1: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 3, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); - // alertExecutionStatus for test:1: {\"lastExecutionDate\":\"1970-01-01T00:00:00.000Z\",\"status\":\"error\",\"error\":{\"reason\":\"unknown\",\"message\":\"Cannot read property 'catch' of undefined\"}} + // ruleExecutionStatus for test:1: {\"lastExecutionDate\":\"1970-01-01T00:00:00.000Z\",\"status\":\"error\",\"error\":{\"reason\":\"unknown\",\"message\":\"Cannot read property 'catch' of undefined\"}} const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-start', category: ['alerts'], @@ -471,7 +469,7 @@ describe('Task Runner', () => { }, ], }, - message: `alert execution start: "1"`, + message: `rule execution start: "1"`, rule: { category: 'test', id: '1', @@ -503,12 +501,12 @@ describe('Task Runner', () => { }, ], }, - message: "test:1: 'alert-name' created new instance: '1'", + message: "test:1: 'rule-name' created new alert: '1'", rule: { category: 'test', id: '1', license: 'basic', - name: 'alert-name', + name: 'rule-name', namespace: undefined, ruleset: 'alerts', }, @@ -532,12 +530,12 @@ describe('Task Runner', () => { ], }, message: - "test:1: 'alert-name' active instance: '1' in actionGroup(subgroup): 'default(subDefault)'", + "test:1: 'rule-name' active alert: '1' in actionGroup(subgroup): 'default(subDefault)'", rule: { category: 'test', id: '1', license: 'basic', - name: 'alert-name', + name: 'rule-name', namespace: undefined, ruleset: 'alerts', }, @@ -571,18 +569,17 @@ describe('Task Runner', () => { ], }, message: - "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup(subgroup): 'default(subDefault)' action: action:1", + "alert: test:1: 'rule-name' instanceId: '1' scheduled actionGroup(subgroup): 'default(subDefault)' action: action:1", rule: { category: 'test', id: '1', license: 'basic', - name: 'alert-name', + name: 'rule-name', namespace: undefined, ruleset: 'alerts', }, }); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(5, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute', category: ['alerts'], kind: 'alert', outcome: 'success' }, kibana: { alerting: { @@ -602,12 +599,12 @@ describe('Task Runner', () => { }, ], }, - message: "alert executed: test:1: 'alert-name'", + message: "rule executed: test:1: 'rule-name'", rule: { category: 'test', id: '1', license: 'basic', - name: 'alert-name', + name: 'rule-name', ruleset: 'alerts', }, }); @@ -617,7 +614,7 @@ describe('Task Runner', () => { test('actionsPlugin.execute is skipped if muteAll is true', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -631,12 +628,12 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, muteAll: true, }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ @@ -653,25 +650,24 @@ describe('Task Runner', () => { const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(5); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, - `alert test:1: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:1: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 3, - `no scheduling of actions for alert test:1: 'alert-name': alert is muted.` + `no scheduling of actions for rule test:1: 'rule-name': rule is muted.` ); expect(logger.debug).nthCalledWith( 4, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-start', category: ['alerts'], @@ -692,7 +688,7 @@ describe('Task Runner', () => { }, ], }, - message: `alert execution start: \"1\"`, + message: `rule execution start: \"1\"`, rule: { category: 'test', id: '1', @@ -723,12 +719,12 @@ describe('Task Runner', () => { }, ], }, - message: "test:1: 'alert-name' created new instance: '1'", + message: "test:1: 'rule-name' created new alert: '1'", rule: { category: 'test', id: '1', license: 'basic', - name: 'alert-name', + name: 'rule-name', namespace: undefined, ruleset: 'alerts', }, @@ -756,18 +752,17 @@ describe('Task Runner', () => { }, ], }, - message: "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + message: "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", rule: { category: 'test', id: '1', license: 'basic', - name: 'alert-name', + name: 'rule-name', namespace: undefined, ruleset: 'alerts', }, }); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(4, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute', category: ['alerts'], @@ -792,19 +787,19 @@ describe('Task Runner', () => { }, ], }, - message: "alert executed: test:1: 'alert-name'", + message: "rule executed: test:1: 'rule-name'", rule: { category: 'test', id: '1', license: 'basic', - name: 'alert-name', + name: 'rule-name', ruleset: 'alerts', }, }); }); testAgainstEphemeralSupport( - 'skips firing actions for active instance if instance is muted', + 'skips firing actions for active alert if alert is muted', ( customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, enqueueFunction: (options: ExecuteOptions) => Promise @@ -817,7 +812,7 @@ describe('Task Runner', () => { true ); actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -832,12 +827,12 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, customTaskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, mutedInstanceIds: ['2'], }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ @@ -854,26 +849,26 @@ describe('Task Runner', () => { const logger = customTaskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(5); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, - `alert test:1: 'alert-name' has 2 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"},{\"instanceId\":\"2\",\"actionGroup\":\"default\"}]` + `rule test:1: 'rule-name' has 2 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"},{\"instanceId\":\"2\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 3, - `skipping scheduling of actions for '2' in alert test:1: 'alert-name': instance is muted` + `skipping scheduling of actions for '2' in rule test:1: 'rule-name': rule is muted` ); expect(logger.debug).nthCalledWith( 4, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); } ); - test('actionsPlugin.execute is not called when notifyWhen=onActionGroupChange and alert instance state does not change', async () => { + test('actionsPlugin.execute is not called when notifyWhen=onActionGroupChange and alert alert state does not change', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -887,7 +882,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -909,7 +904,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ @@ -931,7 +926,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -954,7 +948,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -989,19 +983,18 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -1028,12 +1021,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -1043,7 +1036,7 @@ describe('Task Runner', () => { }); testAgainstEphemeralSupport( - 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state has changed', + 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert alert state has changed', ( customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, enqueueFunction: (options: ExecuteOptions) => Promise @@ -1055,7 +1048,7 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( true ); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -1069,7 +1062,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -1087,7 +1080,7 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ @@ -1105,7 +1098,7 @@ describe('Task Runner', () => { ); testAgainstEphemeralSupport( - 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state subgroup has changed', + 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert state subgroup has changed', ( customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, enqueueFunction: (options: ExecuteOptions) => Promise @@ -1118,7 +1111,7 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( true ); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -1134,7 +1127,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -1156,7 +1149,7 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ @@ -1187,7 +1180,7 @@ describe('Task Runner', () => { true ); actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -1201,11 +1194,11 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, customTaskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -1272,7 +1265,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -1295,7 +1287,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -1330,12 +1322,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' created new instance: '1'", + "message": "test:1: 'rule-name' created new alert: '1'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -1366,12 +1358,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -1406,19 +1398,18 @@ describe('Task Runner', () => { }, ], }, - "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", + "message": "alert: test:1: 'rule-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -1445,12 +1436,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -1475,7 +1466,7 @@ describe('Task Runner', () => { ); actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -1489,7 +1480,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -1516,7 +1507,7 @@ describe('Task Runner', () => { }, customTaskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -1548,18 +1539,18 @@ describe('Task Runner', () => { const logger = customTaskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(5); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, - `alert test:1: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:1: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 3, - `alert test:1: 'alert-name' has 1 recovered alert instances: [\"2\"]` + `rule test:1: 'rule-name' has 1 recovered alerts: [\"2\"]` ); expect(logger.debug).nthCalledWith( 4, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; @@ -1569,7 +1560,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -1592,7 +1582,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -1627,12 +1617,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' instance '2' has recovered", + "message": "test:1: 'rule-name' alert '2' has recovered", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -1663,12 +1653,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -1703,12 +1693,12 @@ describe('Task Runner', () => { }, ], }, - "message": "alert: test:1: 'alert-name' instanceId: '2' scheduled actionGroup: 'recovered' action: action:2", + "message": "alert: test:1: 'rule-name' instanceId: '2' scheduled actionGroup: 'recovered' action: action:2", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -1743,19 +1733,18 @@ describe('Task Runner', () => { }, ], }, - "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", + "message": "alert: test:1: 'rule-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -1782,12 +1771,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -1842,7 +1831,7 @@ describe('Task Runner', () => { ); actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -1859,7 +1848,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -1875,7 +1864,7 @@ describe('Task Runner', () => { }, customTaskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: alertId, type: 'alert', @@ -1905,16 +1894,16 @@ describe('Task Runner', () => { const logger = customTaskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledWith( - `alert test:${alertId}: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:${alertId}: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 3, - `alert test:${alertId}: 'alert-name' has 1 recovered alert instances: [\"2\"]` + `rule test:${alertId}: 'rule-name' has 1 recovered alerts: [\"2\"]` ); expect(logger.debug).nthCalledWith( 4, - `alertExecutionStatus for test:${alertId}: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` + `ruleExecutionStatus for test:${alertId}: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` ); const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; @@ -1945,13 +1934,13 @@ describe('Task Runner', () => { id: 'customRecovered', name: 'Custom Recovered', }; - const alertTypeWithCustomRecovery = { - ...alertType, + const ruleTypeWithCustomRecovery = { + ...ruleType, recoveryActionGroup, actionGroups: [{ id: 'default', name: 'Default' }, recoveryActionGroup], }; - alertTypeWithCustomRecovery.executor.mockImplementation( + ruleTypeWithCustomRecovery.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -1965,7 +1954,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertTypeWithCustomRecovery, + ruleTypeWithCustomRecovery, { ...mockedTaskInstance, state: { @@ -1979,7 +1968,7 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, actions: [ { group: 'default', @@ -2060,7 +2049,7 @@ describe('Task Runner', () => { ); test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => { - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -2075,7 +2064,7 @@ describe('Task Runner', () => { ); const date = new Date().toISOString(); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -2102,7 +2091,7 @@ describe('Task Runner', () => { }, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -2139,7 +2128,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -2162,7 +2150,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -2198,12 +2186,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' instance '2' has recovered", + "message": "test:1: 'rule-name' alert '2' has recovered", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -2234,19 +2222,18 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -2273,12 +2260,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -2290,7 +2277,7 @@ describe('Task Runner', () => { test('validates params before executing the alert type', async () => { const taskRunner = new TaskRunner( { - ...alertType, + ...ruleType, validate: { params: schema.object({ param1: schema.string(), @@ -2306,7 +2293,7 @@ describe('Task Runner', () => { }, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -2325,17 +2312,17 @@ describe('Task Runner', () => { } `); expect(taskRunnerFactoryInitializerParams.logger.error).toHaveBeenCalledWith( - `Executing Alert foo:test:1 has resulted in Error: params invalid: [param1]: expected value of type [string] but got [undefined]` + `Executing Rule foo:test:1 has resulted in Error: params invalid: [param1]: expected value of type [string] but got [undefined]` ); }); test('uses API key when provided', async () => { const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -2365,11 +2352,11 @@ describe('Task Runner', () => { test(`doesn't use API key when not provided`, async () => { const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -2397,14 +2384,14 @@ describe('Task Runner', () => { test('rescheduled the Alert if the schedule has update during a task run', async () => { const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValueOnce(mockedRuleTypeSavedObject); rulesClient.get.mockResolvedValueOnce({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, schedule: { interval: '30s' }, }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ @@ -2431,8 +2418,8 @@ describe('Task Runner', () => { `); }); - test('recovers gracefully when the AlertType executor throws an exception', async () => { - alertType.executor.mockImplementation( + test('recovers gracefully when the RuleType executor throws an exception', async () => { + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -2447,12 +2434,12 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -2481,7 +2468,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -2504,7 +2490,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -2515,7 +2501,6 @@ describe('Task Runner', () => { ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "error": Object { "message": "OMG", }, @@ -2546,7 +2531,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution failure: test:1: 'alert-name'", + "message": "rule execution failure: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", @@ -2565,12 +2550,12 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); const runnerResult = await taskRunner.run(); @@ -2590,7 +2575,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -2613,7 +2597,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -2624,7 +2608,6 @@ describe('Task Runner', () => { ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "error": Object { "message": "OMG", }, @@ -2674,12 +2657,12 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -2708,7 +2691,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -2731,7 +2713,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -2742,7 +2724,6 @@ describe('Task Runner', () => { ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "error": Object { "message": "OMG", }, @@ -2792,12 +2773,12 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -2826,7 +2807,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -2849,7 +2829,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -2860,7 +2840,6 @@ describe('Task Runner', () => { ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "error": Object { "message": "OMG", }, @@ -2910,7 +2889,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + ruleType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -2943,7 +2922,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -2966,7 +2944,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -2977,7 +2955,6 @@ describe('Task Runner', () => { ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "error": Object { "message": "OMG", }, @@ -3031,7 +3008,7 @@ describe('Task Runner', () => { const legacyTaskInstance = omit(mockedTaskInstance, 'schedule'); const taskRunner = new TaskRunner( - alertType, + ruleType, legacyTaskInstance, taskRunnerFactoryInitializerParams ); @@ -3063,7 +3040,7 @@ describe('Task Runner', () => { previousStartedAt: '1970-01-05T00:00:00.000Z', }; - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -3078,7 +3055,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: originalAlertSate, @@ -3086,7 +3063,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -3110,7 +3087,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, params: { @@ -3135,7 +3112,7 @@ describe('Task Runner', () => { return taskRunner.run().catch((ex) => { expect(ex).toMatchInlineSnapshot(`[Error: Saved object [alert/1] not found]`); expect(logger.debug).toHaveBeenCalledWith( - `Executing Alert foo:test:1 has resulted in Error: Saved object [alert/1] not found` + `Executing Rule foo:test:1 has resulted in Error: Saved object [alert/1] not found` ); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).nthCalledWith( @@ -3152,7 +3129,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, params: { @@ -3177,7 +3154,7 @@ describe('Task Runner', () => { return taskRunner.run().catch((ex) => { expect(ex).toMatchInlineSnapshot(`[Error: Saved object [alert/1] not found]`); expect(logger.debug).toHaveBeenCalledWith( - `Executing Alert test space:test:1 has resulted in Error: Saved object [alert/1] not found` + `Executing Rule test space:test:1 has resulted in Error: Saved object [alert/1] not found` ); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).nthCalledWith( @@ -3190,7 +3167,7 @@ describe('Task Runner', () => { test('start time is logged for new alerts', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -3205,7 +3182,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -3216,7 +3193,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', actions: [], }); @@ -3238,7 +3215,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -3261,7 +3237,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -3296,12 +3272,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' created new instance: '1'", + "message": "test:1: 'rule-name' created new alert: '1'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -3332,12 +3308,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' created new instance: '2'", + "message": "test:1: 'rule-name' created new alert: '2'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -3368,12 +3344,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -3404,19 +3380,18 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '2' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '2' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -3443,12 +3418,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -3460,7 +3435,7 @@ describe('Task Runner', () => { test('duration is updated for active alerts when alert state contains start time', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -3475,7 +3450,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -3503,7 +3478,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', actions: [], }); @@ -3525,7 +3500,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -3548,7 +3522,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -3583,12 +3557,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -3619,19 +3593,18 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '2' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '2' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -3658,12 +3631,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -3675,7 +3648,7 @@ describe('Task Runner', () => { test('duration is not calculated for active alerts when alert state does not contain start time', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -3690,7 +3663,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -3710,7 +3683,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', actions: [], }); @@ -3732,7 +3705,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -3755,7 +3727,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -3788,12 +3760,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -3822,19 +3794,18 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '2' in actionGroup: 'default'", + "message": "test:1: 'rule-name' active alert: '2' in actionGroup: 'default'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -3861,12 +3832,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -3878,9 +3849,9 @@ describe('Task Runner', () => { test('end is logged for active alerts when alert state contains start time and alert recovers', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation(async () => {}); + ruleType.executor.mockImplementation(async () => {}); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -3908,7 +3879,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', actions: [], }); @@ -3930,7 +3901,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -3953,7 +3923,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -3988,12 +3958,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' instance '1' has recovered", + "message": "test:1: 'rule-name' alert '1' has recovered", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -4024,19 +3994,18 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' instance '2' has recovered", + "message": "test:1: 'rule-name' alert '2' has recovered", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -4063,12 +4032,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -4080,7 +4049,7 @@ describe('Task Runner', () => { test('end calculation is skipped for active alerts when alert state does not contain start time and alert recovers', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( + ruleType.executor.mockImplementation( async ({ services: executorServices, }: AlertExecutorOptions< @@ -4092,7 +4061,7 @@ describe('Task Runner', () => { >) => {} ); const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -4112,7 +4081,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); rulesClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, + ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', actions: [], }); @@ -4134,7 +4103,6 @@ describe('Task Runner', () => { Array [ Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -4157,7 +4125,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -4189,12 +4157,12 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' instance '1' has recovered", + "message": "test:1: 'rule-name' alert '1' has recovered", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -4222,19 +4190,18 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' instance '2' has recovered", + "message": "test:1: 'rule-name' alert '2' has recovered", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, ], Array [ Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "category": Array [ @@ -4261,12 +4228,12 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", + "message": "rule executed: test:1: 'rule-name'", "rule": Object { "category": "test", "id": "1", "license": "basic", - "name": "alert-name", + "name": "rule-name", "ruleset": "alerts", }, }, @@ -4277,7 +4244,7 @@ describe('Task Runner', () => { test('successfully executes the task with ephemeral tasks enabled', async () => { const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state: { @@ -4290,7 +4257,7 @@ describe('Task Runner', () => { supportsEphemeralTasks: true, } ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -4313,8 +4280,8 @@ describe('Task Runner', () => { }, } `); - expect(alertType.executor).toHaveBeenCalledTimes(1); - const call = alertType.executor.mock.calls[0][0]; + expect(ruleType.executor).toHaveBeenCalledTimes(1); + const call = ruleType.executor.mock.calls[0][0]; expect(call.params).toMatchInlineSnapshot(` Object { "bar": true, @@ -4323,13 +4290,13 @@ describe('Task Runner', () => { expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); expect(call.previousStartedAt).toMatchInlineSnapshot(`1969-12-31T23:55:00.000Z`); expect(call.state).toMatchInlineSnapshot(`Object {}`); - expect(call.name).toBe('alert-name'); - expect(call.tags).toEqual(['alert-', '-tags']); - expect(call.createdBy).toBe('alert-creator'); - expect(call.updatedBy).toBe('alert-updater'); + expect(call.name).toBe('rule-name'); + expect(call.tags).toEqual(['rule-', '-tags']); + expect(call.createdBy).toBe('rule-creator'); + expect(call.updatedBy).toBe('rule-updater'); expect(call.rule).not.toBe(null); - expect(call.rule.name).toBe('alert-name'); - expect(call.rule.tags).toEqual(['alert-', '-tags']); + expect(call.rule.name).toBe('rule-name'); + expect(call.rule.tags).toEqual(['rule-', '-tags']); expect(call.rule.consumer).toBe('bar'); expect(call.rule.enabled).toBe(true); expect(call.rule.schedule).toMatchInlineSnapshot(` @@ -4337,15 +4304,15 @@ describe('Task Runner', () => { "interval": "10s", } `); - expect(call.rule.createdBy).toBe('alert-creator'); - expect(call.rule.updatedBy).toBe('alert-updater'); + expect(call.rule.createdBy).toBe('rule-creator'); + expect(call.rule.updatedBy).toBe('rule-updater'); expect(call.rule.createdAt).toBe(mockDate); expect(call.rule.updatedAt).toBe(mockDate); expect(call.rule.notifyWhen).toBe('onActiveAlert'); expect(call.rule.throttle).toBe(null); expect(call.rule.producer).toBe('alerts'); expect(call.rule.ruleTypeId).toBe('test'); - expect(call.rule.ruleTypeName).toBe('My test alert'); + expect(call.rule.ruleTypeName).toBe('My test rule'); expect(call.rule.actions).toMatchInlineSnapshot(` Array [ Object { @@ -4372,10 +4339,10 @@ describe('Task Runner', () => { const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(3); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' + 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' ); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; @@ -4383,7 +4350,6 @@ describe('Task Runner', () => { expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent.mock.calls[0][0]).toMatchInlineSnapshot(` Object { - "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute-start", "category": Array [ @@ -4406,7 +4372,7 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert execution start: \\"1\\"", + "message": "rule execution start: \\"1\\"", "rule": Object { "category": "test", "id": "1", @@ -4439,14 +4405,14 @@ describe('Task Runner', () => { previousStartedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), }; const taskRunner = new TaskRunner( - alertType, + ruleType, { ...mockedTaskInstance, state, }, taskRunnerFactoryInitializerParams ); - rulesClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', type: 'alert', @@ -4463,7 +4429,6 @@ describe('Task Runner', () => { const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); expect(eventLogger.logEvent.mock.calls[0][0]).toStrictEqual({ - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-start', kind: 'alert', @@ -4481,10 +4446,9 @@ describe('Task Runner', () => { category: 'test', ruleset: 'alerts', }, - message: 'alert execution start: "1"', + message: 'rule execution start: "1"', }); expect(eventLogger.logEvent.mock.calls[1][0]).toStrictEqual({ - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute', kind: 'alert', diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 0cf5202787392..91c9683b948a0 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -15,19 +15,19 @@ import { ConcreteTaskInstance, throwUnrecoverableError } from '../../../task_man import { createExecutionHandler, ExecutionHandler } from './create_execution_handler'; import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { - validateAlertTypeParams, + validateRuleTypeParams, executionStatusFromState, executionStatusFromError, - alertExecutionStatusToRaw, + ruleExecutionStatusToRaw, ErrorWithReason, ElasticsearchError, } from '../lib'; import { - RawAlert, + RawRule, IntervalSchedule, Services, RawAlertInstance, - AlertTaskState, + RuleTaskState, Alert, SanitizedAlert, AlertExecutionStatus, @@ -49,7 +49,7 @@ import { AlertInstanceContext, WithoutReservedActionGroups, } from '../../common'; -import { NormalizedAlertType, UntypedNormalizedAlertType } from '../rule_type_registry'; +import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; import { createAlertEventLogRecordObject, @@ -61,13 +61,13 @@ const FALLBACK_RETRY_INTERVAL = '5m'; // 1,000,000 nanoseconds in 1 millisecond const Millis2Nanos = 1000 * 1000; -interface AlertTaskRunResult { - state: AlertTaskState; +interface RuleTaskRunResult { + state: RuleTaskState; schedule: IntervalSchedule | undefined; } -interface AlertTaskInstance extends ConcreteTaskInstance { - state: AlertTaskState; +interface RuleTaskInstance extends ConcreteTaskInstance { + state: RuleTaskState; } export class TaskRunner< @@ -81,9 +81,9 @@ export class TaskRunner< > { private context: TaskRunnerContext; private logger: Logger; - private taskInstance: AlertTaskInstance; + private taskInstance: RuleTaskInstance; private ruleName: string | null; - private alertType: NormalizedAlertType< + private ruleType: NormalizedRuleType< Params, ExtractedParams, State, @@ -96,7 +96,7 @@ export class TaskRunner< private cancelled: boolean; constructor( - alertType: NormalizedAlertType< + ruleType: NormalizedRuleType< Params, ExtractedParams, State, @@ -110,7 +110,7 @@ export class TaskRunner< ) { this.context = context; this.logger = context.logger; - this.alertType = alertType; + this.ruleType = ruleType; this.ruleName = null; this.taskInstance = taskInstanceToAlertTaskInstance(taskInstance); this.ruleTypeRegistry = context.ruleTypeRegistry; @@ -126,7 +126,7 @@ export class TaskRunner< // scoped with the API key to fetch the remaining data. const { attributes: { apiKey, enabled }, - } = await this.context.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + } = await this.context.encryptedSavedObjectsClient.getDecryptedAsInternalUser( 'alert', ruleId, { namespace } @@ -135,7 +135,7 @@ export class TaskRunner< return { apiKey, enabled }; } - private getFakeKibanaRequest(spaceId: string, apiKey: RawAlert['apiKey']) { + private getFakeKibanaRequest(spaceId: string, apiKey: RawRule['apiKey']) { const requestHeaders: Record = {}; if (apiKey) { @@ -165,21 +165,21 @@ export class TaskRunner< private getServicesWithSpaceLevelPermissions( spaceId: string, - apiKey: RawAlert['apiKey'] + apiKey: RawRule['apiKey'] ): [Services, PublicMethodsOf] { const request = this.getFakeKibanaRequest(spaceId, apiKey); return [this.context.getServices(request), this.context.getRulesClientWithRequest(request)]; } private getExecutionHandler( - alertId: string, - alertName: string, + ruleId: string, + ruleName: string, tags: string[] | undefined, spaceId: string, - apiKey: RawAlert['apiKey'], + apiKey: RawRule['apiKey'], kibanaBaseUrl: string | undefined, actions: Alert['actions'], - alertParams: Params + ruleParams: Params ) { return createExecutionHandler< Params, @@ -190,43 +190,43 @@ export class TaskRunner< ActionGroupIds, RecoveryActionGroupId >({ - alertId, - alertName, + ruleId, + ruleName, tags, logger: this.logger, actionsPlugin: this.context.actionsPlugin, apiKey, actions, spaceId, - alertType: this.alertType, + ruleType: this.ruleType, kibanaBaseUrl, eventLogger: this.context.eventLogger, request: this.getFakeKibanaRequest(spaceId, apiKey), - alertParams, + ruleParams, supportsEphemeralTasks: this.context.supportsEphemeralTasks, - maxEphemeralActionsPerAlert: this.context.maxEphemeralActionsPerAlert, + maxEphemeralActionsPerRule: this.context.maxEphemeralActionsPerRule, }); } private async updateRuleExecutionStatus( - alertId: string, + ruleId: string, namespace: string | undefined, executionStatus: AlertExecutionStatus ) { const client = this.context.internalSavedObjectsRepository; const attributes = { - executionStatus: alertExecutionStatusToRaw(executionStatus), + executionStatus: ruleExecutionStatusToRaw(executionStatus), }; try { - await partiallyUpdateAlert(client, alertId, attributes, { + await partiallyUpdateAlert(client, ruleId, attributes, { ignore404: true, namespace, refresh: false, }); } catch (err) { this.logger.error( - `error updating rule execution status for ${this.alertType.id}:${alertId} ${err.message}` + `error updating rule execution status for ${this.ruleType.id}:${ruleId} ${err.message}` ); } } @@ -238,12 +238,12 @@ export class TaskRunner< } // if execution has been cancelled, return true if EITHER alerting config or rule type indicate to proceed with scheduling actions - return !this.context.cancelAlertsOnRuleTimeout || !this.alertType.cancelAlertsOnRuleTimeout; + return !this.context.cancelAlertsOnRuleTimeout || !this.ruleType.cancelAlertsOnRuleTimeout; } - async executeAlertInstance( - alertInstanceId: string, - alertInstance: AlertInstance, + async executeAlert( + alertId: string, + alert: AlertInstance, executionHandler: ExecutionHandler ) { const { @@ -251,20 +251,20 @@ export class TaskRunner< subgroup: actionSubgroup, context, state, - } = alertInstance.getScheduledActionOptions()!; - alertInstance.updateLastScheduledActions(actionGroup, actionSubgroup); - alertInstance.unscheduleActions(); - return executionHandler({ actionGroup, actionSubgroup, context, state, alertInstanceId }); + } = alert.getScheduledActionOptions()!; + alert.updateLastScheduledActions(actionGroup, actionSubgroup); + alert.unscheduleActions(); + return executionHandler({ actionGroup, actionSubgroup, context, state, alertId }); } - async executeAlertInstances( + async executeAlerts( services: Services, - alert: SanitizedAlert, + rule: SanitizedAlert, params: Params, executionHandler: ExecutionHandler, spaceId: string, event: Event - ): Promise { + ): Promise { const { alertTypeId, consumer, @@ -281,48 +281,45 @@ export class TaskRunner< updatedAt, enabled, actions, - } = alert; + } = rule; const { - params: { alertId }, + params: { alertId: ruleId }, state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, } = this.taskInstance; const namespace = this.context.spaceIdToNamespace(spaceId); - const alertType = this.ruleTypeRegistry.get(alertTypeId); + const ruleType = this.ruleTypeRegistry.get(alertTypeId); - const alertInstances = mapValues< + const alerts = mapValues< Record, AlertInstance - >( - alertRawInstances, - (rawAlertInstance) => new AlertInstance(rawAlertInstance) - ); - const originalAlertInstances = cloneDeep(alertInstances); - const originalAlertInstanceIds = new Set(Object.keys(originalAlertInstances)); + >(alertRawInstances, (rawAlert) => new AlertInstance(rawAlert)); + const originalAlerts = cloneDeep(alerts); + const originalAlertIds = new Set(Object.keys(originalAlerts)); const eventLogger = this.context.eventLogger; - const alertLabel = `${this.alertType.id}:${alertId}: '${name}'`; + const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; - let updatedAlertTypeState: void | Record; + let updatedRuleTypeState: void | Record; try { const ctx = { type: 'alert', - name: `execute ${alert.alertTypeId}`, - id: alertId, - description: `execute [${alert.alertTypeId}] with name [${name}] in [${ + name: `execute ${rule.alertTypeId}`, + id: ruleId, + description: `execute [${rule.alertTypeId}] with name [${name}] in [${ namespace ?? 'default' }] namespace`, }; - updatedAlertTypeState = await this.context.executionContext.withContext(ctx, () => - this.alertType.executor({ - alertId, + updatedRuleTypeState = await this.context.executionContext.withContext(ctx, () => + this.ruleType.executor({ + alertId: ruleId, services: { ...services, alertInstanceFactory: createAlertInstanceFactory< InstanceState, InstanceContext, WithoutReservedActionGroups - >(alertInstances), + >(alerts), shouldWriteAlerts: () => this.shouldLogAndScheduleActionsForAlerts(), }, params, @@ -339,9 +336,9 @@ export class TaskRunner< name, tags, consumer, - producer: alertType.producer, - ruleTypeId: alert.alertTypeId, - ruleTypeName: alertType.name, + producer: ruleType.producer, + ruleTypeId: rule.alertTypeId, + ruleTypeName: ruleType.name, enabled, schedule, actions, @@ -355,7 +352,7 @@ export class TaskRunner< }) ); } catch (err) { - event.message = `alert execution failure: ${alertLabel}`; + event.message = `rule execution failure: ${ruleLabel}`; event.error = event.error || {}; event.error.message = err.message; event.event = event.event || {}; @@ -363,93 +360,85 @@ export class TaskRunner< throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Execute, err); } - event.message = `alert executed: ${alertLabel}`; + event.message = `rule executed: ${ruleLabel}`; event.event = event.event || {}; event.event.outcome = 'success'; event.rule = { ...event.rule, - name: alert.name, + name: rule.name, }; - // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object - const instancesWithScheduledActions = pickBy( - alertInstances, - (alertInstance: AlertInstance) => - alertInstance.hasScheduledActions() + // Cleanup alerts that are no longer scheduling actions to avoid over populating the alertInstances object + const alertsWithScheduledActions = pickBy( + alerts, + (alert: AlertInstance) => alert.hasScheduledActions() ); - const recoveredAlertInstances = pickBy( - alertInstances, - (alertInstance: AlertInstance, id) => - !alertInstance.hasScheduledActions() && originalAlertInstanceIds.has(id) + const recoveredAlerts = pickBy( + alerts, + (alert: AlertInstance, id) => + !alert.hasScheduledActions() && originalAlertIds.has(id) ); - logActiveAndRecoveredInstances({ + logActiveAndRecoveredAlerts({ logger: this.logger, - activeAlertInstances: instancesWithScheduledActions, - recoveredAlertInstances, - alertLabel, + activeAlerts: alertsWithScheduledActions, + recoveredAlerts, + ruleLabel, }); trackAlertDurations({ - originalAlerts: originalAlertInstances, - currentAlerts: instancesWithScheduledActions, - recoveredAlerts: recoveredAlertInstances, + originalAlerts, + currentAlerts: alertsWithScheduledActions, + recoveredAlerts, }); if (this.shouldLogAndScheduleActionsForAlerts()) { - generateNewAndRecoveredInstanceEvents({ + generateNewAndRecoveredAlertEvents({ eventLogger, - originalAlertInstances, - currentAlertInstances: instancesWithScheduledActions, - recoveredAlertInstances, - alertId, - alertLabel, + originalAlerts, + currentAlerts: alertsWithScheduledActions, + recoveredAlerts, + ruleId, + ruleLabel, namespace, - ruleType: alertType, - rule: alert, + ruleType, + rule, }); } if (!muteAll && this.shouldLogAndScheduleActionsForAlerts()) { - const mutedInstanceIdsSet = new Set(mutedInstanceIds); + const mutedAlertIdsSet = new Set(mutedInstanceIds); - scheduleActionsForRecoveredInstances({ - recoveryActionGroup: this.alertType.recoveryActionGroup, - recoveredAlertInstances, + scheduleActionsForRecoveredAlerts({ + recoveryActionGroup: this.ruleType.recoveryActionGroup, + recoveredAlerts, executionHandler, - mutedInstanceIdsSet, + mutedAlertIdsSet, logger: this.logger, - alertLabel, + ruleLabel, }); - const instancesToExecute = + const alertsToExecute = notifyWhen === 'onActionGroupChange' - ? Object.entries(instancesWithScheduledActions).filter( - ([alertInstanceName, alertInstance]: [ - string, - AlertInstance - ]) => { - const shouldExecuteAction = - alertInstance.scheduledActionGroupOrSubgroupHasChanged(); + ? Object.entries(alertsWithScheduledActions).filter( + ([alertName, alert]: [string, AlertInstance]) => { + const shouldExecuteAction = alert.scheduledActionGroupOrSubgroupHasChanged(); if (!shouldExecuteAction) { this.logger.debug( - `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is active but action group has not changed` + `skipping scheduling of actions for '${alertName}' in rule ${ruleLabel}: alert is active but action group has not changed` ); } return shouldExecuteAction; } ) - : Object.entries(instancesWithScheduledActions).filter( - ([alertInstanceName, alertInstance]: [ - string, - AlertInstance - ]) => { - const throttled = alertInstance.isThrottled(throttle); - const muted = mutedInstanceIdsSet.has(alertInstanceName); + : Object.entries(alertsWithScheduledActions).filter( + ([alertName, alert]: [string, AlertInstance]) => { + const throttled = alert.isThrottled(throttle); + const muted = mutedAlertIdsSet.has(alertName); const shouldExecuteAction = !throttled && !muted; if (!shouldExecuteAction) { this.logger.debug( - `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is ${ + `skipping scheduling of actions for '${alertName}' in rule ${ruleLabel}: rule is ${ muted ? 'muted' : 'throttled' }` ); @@ -459,71 +448,64 @@ export class TaskRunner< ); await Promise.all( - instancesToExecute.map( - ([id, alertInstance]: [string, AlertInstance]) => - this.executeAlertInstance(id, alertInstance, executionHandler) + alertsToExecute.map( + ([alertId, alert]: [string, AlertInstance]) => + this.executeAlert(alertId, alert, executionHandler) ) ); } else { if (muteAll) { - this.logger.debug(`no scheduling of actions for alert ${alertLabel}: alert is muted.`); + this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is muted.`); } if (!this.shouldLogAndScheduleActionsForAlerts()) { this.logger.debug( - `no scheduling of actions for alert ${alertLabel}: alert execution has been cancelled.` + `no scheduling of actions for rule ${ruleLabel}: rule execution has been cancelled.` ); } } return { - alertTypeState: updatedAlertTypeState || undefined, + alertTypeState: updatedRuleTypeState || undefined, alertInstances: mapValues< Record>, RawAlertInstance - >(instancesWithScheduledActions, (alertInstance) => alertInstance.toRaw()), + >(alertsWithScheduledActions, (alert) => alert.toRaw()), }; } - async validateAndExecuteAlert( + async validateAndExecuteRule( services: Services, - apiKey: RawAlert['apiKey'], - alert: SanitizedAlert, + apiKey: RawRule['apiKey'], + rule: SanitizedAlert, event: Event ) { const { - params: { alertId, spaceId }, + params: { alertId: ruleId, spaceId }, } = this.taskInstance; // Validate - const validatedParams = validateAlertTypeParams(alert.params, this.alertType.validate?.params); + const validatedParams = validateRuleTypeParams(rule.params, this.ruleType.validate?.params); const executionHandler = this.getExecutionHandler( - alertId, - alert.name, - alert.tags, + ruleId, + rule.name, + rule.tags, spaceId, apiKey, this.context.kibanaBaseUrl, - alert.actions, - alert.params - ); - return this.executeAlertInstances( - services, - alert, - validatedParams, - executionHandler, - spaceId, - event + rule.actions, + rule.params ); + return this.executeAlerts(services, rule, validatedParams, executionHandler, spaceId, event); } - async loadAlertAttributesAndRun(event: Event): Promise> { + async loadRuleAttributesAndRun(event: Event): Promise> { const { - params: { alertId, spaceId }, + params: { alertId: ruleId, spaceId }, } = this.taskInstance; let enabled: boolean; let apiKey: string | null; try { - const decryptedAttributes = await this.getDecryptedAttributes(alertId, spaceId); + const decryptedAttributes = await this.getDecryptedAttributes(ruleId, spaceId); apiKey = decryptedAttributes.apiKey; enabled = decryptedAttributes.enabled; } catch (err) { @@ -539,48 +521,48 @@ export class TaskRunner< const [services, rulesClient] = this.getServicesWithSpaceLevelPermissions(spaceId, apiKey); - let alert: SanitizedAlert; + let rule: SanitizedAlert; // Ensure API key is still valid and user has access try { - alert = await rulesClient.get({ id: alertId }); + rule = await rulesClient.get({ id: ruleId }); if (apm.currentTransaction) { - apm.currentTransaction.name = `Execute Alerting Rule: "${alert.name}"`; + apm.currentTransaction.name = `Execute Alerting Rule: "${rule.name}"`; apm.currentTransaction.addLabels({ - alerting_rule_consumer: alert.consumer, - alerting_rule_name: alert.name, - alerting_rule_tags: alert.tags.join(', '), - alerting_rule_type_id: alert.alertTypeId, - alerting_rule_params: JSON.stringify(alert.params), + alerting_rule_consumer: rule.consumer, + alerting_rule_name: rule.name, + alerting_rule_tags: rule.tags.join(', '), + alerting_rule_type_id: rule.alertTypeId, + alerting_rule_params: JSON.stringify(rule.params), }); } } catch (err) { throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Read, err); } - this.ruleName = alert.name; + this.ruleName = rule.name; try { - this.ruleTypeRegistry.ensureRuleTypeEnabled(alert.alertTypeId); + this.ruleTypeRegistry.ensureRuleTypeEnabled(rule.alertTypeId); } catch (err) { throw new ErrorWithReason(AlertExecutionStatusErrorReasons.License, err); } return { - state: await promiseResult( - this.validateAndExecuteAlert(services, apiKey, alert, event) + state: await promiseResult( + this.validateAndExecuteRule(services, apiKey, rule, event) ), schedule: asOk( - // fetch the alert again to ensure we return the correct schedule as it may have + // fetch the rule again to ensure we return the correct schedule as it may have // cahnged during the task execution - (await rulesClient.get({ id: alertId })).schedule + (await rulesClient.get({ id: ruleId })).schedule ), }; } - async run(): Promise { + async run(): Promise { const { - params: { alertId, spaceId }, + params: { alertId: ruleId, spaceId }, startedAt, state: originalState, schedule: taskSchedule, @@ -589,22 +571,21 @@ export class TaskRunner< if (apm.currentTransaction) { apm.currentTransaction.name = `Execute Alerting Rule`; apm.currentTransaction.addLabels({ - alerting_rule_id: alertId, + alerting_rule_id: ruleId, }); } const runDate = new Date(); const runDateString = runDate.toISOString(); - this.logger.debug(`executing alert ${this.alertType.id}:${alertId} at ${runDateString}`); + this.logger.debug(`executing rule ${this.ruleType.id}:${ruleId} at ${runDateString}`); const namespace = this.context.spaceIdToNamespace(spaceId); const eventLogger = this.context.eventLogger; const scheduleDelay = runDate.getTime() - this.taskInstance.runAt.getTime(); const event = createAlertEventLogRecordObject({ - timestamp: runDateString, - ruleId: alertId, - ruleType: this.alertType as UntypedNormalizedAlertType, + ruleId, + ruleType: this.ruleType as UntypedNormalizedRuleType, action: EVENT_LOG_ACTIONS.execute, namespace, task: { @@ -613,9 +594,9 @@ export class TaskRunner< }, savedObjects: [ { - id: alertId, + id: ruleId, type: 'alert', - typeId: this.alertType.id, + typeId: this.ruleType.id, relation: SAVED_OBJECT_REL_PRIMARY, }, ], @@ -629,17 +610,17 @@ export class TaskRunner< ...event.event, action: EVENT_LOG_ACTIONS.executeStart, }, - message: `alert execution start: "${alertId}"`, + message: `rule execution start: "${ruleId}"`, }); eventLogger.logEvent(startEvent); - const { state, schedule } = await errorAsAlertTaskRunResult( - this.loadAlertAttributesAndRun(event) + const { state, schedule } = await errorAsRuleTaskRunResult( + this.loadRuleAttributesAndRun(event) ); const executionStatus: AlertExecutionStatus = map( state, - (alertTaskState: AlertTaskState) => executionStatusFromState(alertTaskState), + (ruleTaskState: RuleTaskState) => executionStatusFromState(ruleTaskState), (err: ElasticsearchError) => executionStatusFromError(err) ); @@ -657,7 +638,7 @@ export class TaskRunner< } this.logger.debug( - `alertExecutionStatus for ${this.alertType.id}:${alertId}: ${JSON.stringify(executionStatus)}` + `ruleExecutionStatus for ${this.ruleType.id}:${ruleId}: ${JSON.stringify(executionStatus)}` ); eventLogger.stopTiming(event); @@ -679,7 +660,7 @@ export class TaskRunner< event.error = event.error || {}; event.error.message = event.error.message || executionStatus.error.message; if (!event.message) { - event.message = `${this.alertType.id}:${alertId}: execution failed`; + event.message = `${this.ruleType.id}:${ruleId}: execution failed`; } } @@ -687,27 +668,27 @@ export class TaskRunner< if (!this.cancelled) { this.logger.debug( - `Updating rule task for ${this.alertType.id} rule with id ${alertId} - ${JSON.stringify( + `Updating rule task for ${this.ruleType.id} rule with id ${ruleId} - ${JSON.stringify( executionStatus )}` ); - await this.updateRuleExecutionStatus(alertId, namespace, executionStatus); + await this.updateRuleExecutionStatus(ruleId, namespace, executionStatus); } return { - state: map( + state: map( state, - (stateUpdates: AlertTaskState) => { + (stateUpdates: RuleTaskState) => { return { ...stateUpdates, previousStartedAt: startedAt, }; }, (err: ElasticsearchError) => { - const message = `Executing Alert ${spaceId}:${ - this.alertType.id - }:${alertId} has resulted in Error: ${getEsErrorMessage(err)}`; - if (isAlertSavedObjectNotFoundError(err, alertId)) { + const message = `Executing Rule ${spaceId}:${ + this.ruleType.id + }:${ruleId} has resulted in Error: ${getEsErrorMessage(err)}`; + if (isAlertSavedObjectNotFoundError(err, ruleId)) { this.logger.debug(message); } else { this.logger.error(message); @@ -716,10 +697,10 @@ export class TaskRunner< } ), schedule: resolveErr(schedule, (error) => { - if (isAlertSavedObjectNotFoundError(error, alertId)) { + if (isAlertSavedObjectNotFoundError(error, ruleId)) { const spaceMessage = spaceId ? `in the "${spaceId}" space ` : ''; this.logger.warn( - `Unable to execute rule "${alertId}" ${spaceMessage}because ${error.message} - this rule will not be rescheduled. To restart rule execution, try disabling and re-enabling this rule.` + `Unable to execute rule "${ruleId}" ${spaceMessage}because ${error.message} - this rule will not be rescheduled. To restart rule execution, try disabling and re-enabling this rule.` ); throwUnrecoverableError(error); } @@ -737,43 +718,42 @@ export class TaskRunner< // Write event log entry const { - params: { alertId, spaceId }, + params: { alertId: ruleId, spaceId }, } = this.taskInstance; const namespace = this.context.spaceIdToNamespace(spaceId); this.logger.debug( - `Cancelling rule type ${this.alertType.id} with id ${alertId} - execution exceeded rule type timeout of ${this.alertType.ruleTaskTimeout}` + `Cancelling rule type ${this.ruleType.id} with id ${ruleId} - execution exceeded rule type timeout of ${this.ruleType.ruleTaskTimeout}` ); const eventLogger = this.context.eventLogger; const event: IEvent = { - '@timestamp': new Date().toISOString(), event: { action: EVENT_LOG_ACTIONS.executeTimeout, kind: 'alert', - category: [this.alertType.producer], + category: [this.ruleType.producer], }, - message: `rule: ${this.alertType.id}:${alertId}: '${ + message: `rule: ${this.ruleType.id}:${ruleId}: '${ this.ruleName ?? '' }' execution cancelled due to timeout - exceeded rule type timeout of ${ - this.alertType.ruleTaskTimeout + this.ruleType.ruleTaskTimeout }`, kibana: { saved_objects: [ { rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', - id: alertId, - type_id: this.alertType.id, + id: ruleId, + type_id: this.ruleType.id, namespace, }, ], }, rule: { - id: alertId, - license: this.alertType.minimumLicenseRequired, - category: this.alertType.id, - ruleset: this.alertType.producer, + id: ruleId, + license: this.ruleType.minimumLicenseRequired, + category: this.ruleType.id, + ruleset: this.ruleType.producer, ...(this.ruleName ? { name: this.ruleName } : {}), }, }; @@ -785,13 +765,13 @@ export class TaskRunner< status: 'error', error: { reason: AlertExecutionStatusErrorReasons.Timeout, - message: `${this.alertType.id}:${alertId}: execution cancelled due to timeout - exceeded rule type timeout of ${this.alertType.ruleTaskTimeout}`, + message: `${this.ruleType.id}:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of ${this.ruleType.ruleTaskTimeout}`, }, }; this.logger.debug( - `Updating rule task for ${this.alertType.id} rule with id ${alertId} - execution error due to timeout` + `Updating rule task for ${this.ruleType.id} rule with id ${ruleId} - execution error due to timeout` ); - await this.updateRuleExecutionStatus(alertId, namespace, executionStatus); + await this.updateRuleExecutionStatus(ruleId, namespace, executionStatus); } } @@ -815,13 +795,13 @@ function trackAlertDurations< const recoveredAlertIds = Object.keys(recoveredAlerts); const newAlertIds = without(currentAlertIds, ...originalAlertIds); - // Inject start time into instance state of new instances + // Inject start time into alert state of new alerts for (const id of newAlertIds) { const state = currentAlerts[id].getState(); currentAlerts[id].replaceState({ ...state, start: currentTime }); } - // Calculate duration to date for active instances + // Calculate duration to date for active alerts for (const id of currentAlertIds) { const state = originalAlertIds.includes(id) ? originalAlerts[id].getState() @@ -836,7 +816,7 @@ function trackAlertDurations< }); } - // Inject end time into instance state of recovered instances + // Inject end time into alert state of recovered alerts for (const id of recoveredAlertIds) { const state = recoveredAlerts[id].getState(); const duration = state.start @@ -850,18 +830,18 @@ function trackAlertDurations< } } -interface GenerateNewAndRecoveredInstanceEventsParams< +interface GenerateNewAndRecoveredAlertEventsParams< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext > { eventLogger: IEventLogger; - originalAlertInstances: Dictionary>; - currentAlertInstances: Dictionary>; - recoveredAlertInstances: Dictionary>; - alertId: string; - alertLabel: string; + originalAlerts: Dictionary>; + currentAlerts: Dictionary>; + recoveredAlerts: Dictionary>; + ruleId: string; + ruleLabel: string; namespace: string | undefined; - ruleType: NormalizedAlertType< + ruleType: NormalizedRuleType< AlertTypeParams, AlertTypeParams, AlertTypeState, @@ -877,24 +857,24 @@ interface GenerateNewAndRecoveredInstanceEventsParams< rule: SanitizedAlert; } -function generateNewAndRecoveredInstanceEvents< +function generateNewAndRecoveredAlertEvents< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext ->(params: GenerateNewAndRecoveredInstanceEventsParams) { +>(params: GenerateNewAndRecoveredAlertEventsParams) { const { eventLogger, - alertId, + ruleId, namespace, - currentAlertInstances, - originalAlertInstances, - recoveredAlertInstances, + currentAlerts, + originalAlerts, + recoveredAlerts, rule, ruleType, } = params; - const originalAlertInstanceIds = Object.keys(originalAlertInstances); - const currentAlertInstanceIds = Object.keys(currentAlertInstances); - const recoveredAlertInstanceIds = Object.keys(recoveredAlertInstances); - const newIds = without(currentAlertInstanceIds, ...originalAlertInstanceIds); + const originalAlertIds = Object.keys(originalAlerts); + const currentAlertIds = Object.keys(currentAlerts); + const recoveredAlertIds = Object.keys(recoveredAlerts); + const newIds = without(currentAlertIds, ...originalAlertIds); if (apm.currentTransaction) { apm.currentTransaction.addLabels({ @@ -902,12 +882,12 @@ function generateNewAndRecoveredInstanceEvents< }); } - for (const id of recoveredAlertInstanceIds) { + for (const id of recoveredAlertIds) { const { group: actionGroup, subgroup: actionSubgroup } = - recoveredAlertInstances[id].getLastScheduledActions() ?? {}; - const state = recoveredAlertInstances[id].getState(); - const message = `${params.alertLabel} instance '${id}' has recovered`; - logInstanceEvent( + recoveredAlerts[id].getLastScheduledActions() ?? {}; + const state = recoveredAlerts[id].getState(); + const message = `${params.ruleLabel} alert '${id}' has recovered`; + logAlertEvent( id, EVENT_LOG_ACTIONS.recoveredInstance, message, @@ -919,29 +899,22 @@ function generateNewAndRecoveredInstanceEvents< for (const id of newIds) { const { actionGroup, subgroup: actionSubgroup } = - currentAlertInstances[id].getScheduledActionOptions() ?? {}; - const state = currentAlertInstances[id].getState(); - const message = `${params.alertLabel} created new instance: '${id}'`; - logInstanceEvent( - id, - EVENT_LOG_ACTIONS.newInstance, - message, - state, - actionGroup, - actionSubgroup - ); + currentAlerts[id].getScheduledActionOptions() ?? {}; + const state = currentAlerts[id].getState(); + const message = `${params.ruleLabel} created new alert: '${id}'`; + logAlertEvent(id, EVENT_LOG_ACTIONS.newInstance, message, state, actionGroup, actionSubgroup); } - for (const id of currentAlertInstanceIds) { + for (const id of currentAlertIds) { const { actionGroup, subgroup: actionSubgroup } = - currentAlertInstances[id].getScheduledActionOptions() ?? {}; - const state = currentAlertInstances[id].getState(); - const message = `${params.alertLabel} active instance: '${id}' in ${ + currentAlerts[id].getScheduledActionOptions() ?? {}; + const state = currentAlerts[id].getState(); + const message = `${params.ruleLabel} active alert: '${id}' in ${ actionSubgroup ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` : `actionGroup: '${actionGroup}'` }`; - logInstanceEvent( + logAlertEvent( id, EVENT_LOG_ACTIONS.activeInstance, message, @@ -951,8 +924,8 @@ function generateNewAndRecoveredInstanceEvents< ); } - function logInstanceEvent( - instanceId: string, + function logAlertEvent( + alertId: string, action: string, message: string, state: InstanceState, @@ -970,7 +943,7 @@ function generateNewAndRecoveredInstanceEvents< }, kibana: { alerting: { - instance_id: instanceId, + instance_id: alertId, ...(group ? { action_group_id: group } : {}), ...(subgroup ? { action_subgroup: subgroup } : {}), }, @@ -978,7 +951,7 @@ function generateNewAndRecoveredInstanceEvents< { rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', - id: alertId, + id: ruleId, type_id: ruleType.id, namespace, }, @@ -997,27 +970,25 @@ function generateNewAndRecoveredInstanceEvents< } } -interface ScheduleActionsForRecoveredInstancesParams< +interface ScheduleActionsForRecoveredAlertsParams< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, RecoveryActionGroupId extends string > { logger: Logger; recoveryActionGroup: ActionGroup; - recoveredAlertInstances: Dictionary< - AlertInstance - >; + recoveredAlerts: Dictionary>; executionHandler: ExecutionHandler; - mutedInstanceIdsSet: Set; - alertLabel: string; + mutedAlertIdsSet: Set; + ruleLabel: string; } -function scheduleActionsForRecoveredInstances< +function scheduleActionsForRecoveredAlerts< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, RecoveryActionGroupId extends string >( - params: ScheduleActionsForRecoveredInstancesParams< + params: ScheduleActionsForRecoveredAlertsParams< InstanceState, InstanceContext, RecoveryActionGroupId @@ -1026,96 +997,94 @@ function scheduleActionsForRecoveredInstances< const { logger, recoveryActionGroup, - recoveredAlertInstances, + recoveredAlerts, executionHandler, - mutedInstanceIdsSet, - alertLabel, + mutedAlertIdsSet, + ruleLabel, } = params; - const recoveredIds = Object.keys(recoveredAlertInstances); + const recoveredIds = Object.keys(recoveredAlerts); for (const id of recoveredIds) { - if (mutedInstanceIdsSet.has(id)) { + if (mutedAlertIdsSet.has(id)) { logger.debug( - `skipping scheduling of actions for '${id}' in alert ${alertLabel}: instance is muted` + `skipping scheduling of actions for '${id}' in rule ${ruleLabel}: instance is muted` ); } else { - const instance = recoveredAlertInstances[id]; - instance.updateLastScheduledActions(recoveryActionGroup.id); - instance.unscheduleActions(); + const alert = recoveredAlerts[id]; + alert.updateLastScheduledActions(recoveryActionGroup.id); + alert.unscheduleActions(); executionHandler({ actionGroup: recoveryActionGroup.id, context: {}, state: {}, - alertInstanceId: id, + alertId: id, }); - instance.scheduleActions(recoveryActionGroup.id); + alert.scheduleActions(recoveryActionGroup.id); } } } -interface LogActiveAndRecoveredInstancesParams< +interface LogActiveAndRecoveredAlertsParams< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, RecoveryActionGroupId extends string > { logger: Logger; - activeAlertInstances: Dictionary>; - recoveredAlertInstances: Dictionary< - AlertInstance - >; - alertLabel: string; + activeAlerts: Dictionary>; + recoveredAlerts: Dictionary>; + ruleLabel: string; } -function logActiveAndRecoveredInstances< +function logActiveAndRecoveredAlerts< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, RecoveryActionGroupId extends string >( - params: LogActiveAndRecoveredInstancesParams< + params: LogActiveAndRecoveredAlertsParams< InstanceState, InstanceContext, ActionGroupIds, RecoveryActionGroupId > ) { - const { logger, activeAlertInstances, recoveredAlertInstances, alertLabel } = params; - const activeInstanceIds = Object.keys(activeAlertInstances); - const recoveredInstanceIds = Object.keys(recoveredAlertInstances); + const { logger, activeAlerts, recoveredAlerts, ruleLabel } = params; + const activeAlertIds = Object.keys(activeAlerts); + const recoveredAlertIds = Object.keys(recoveredAlerts); if (apm.currentTransaction) { apm.currentTransaction.addLabels({ - alerting_active_alerts: activeInstanceIds.length, - alerting_recovered_alerts: recoveredInstanceIds.length, + alerting_active_alerts: activeAlertIds.length, + alerting_recovered_alerts: recoveredAlertIds.length, }); } - if (activeInstanceIds.length > 0) { + if (activeAlertIds.length > 0) { logger.debug( - `alert ${alertLabel} has ${activeInstanceIds.length} active alert instances: ${JSON.stringify( - activeInstanceIds.map((instanceId) => ({ - instanceId, - actionGroup: activeAlertInstances[instanceId].getScheduledActionOptions()?.actionGroup, + `rule ${ruleLabel} has ${activeAlertIds.length} active alerts: ${JSON.stringify( + activeAlertIds.map((alertId) => ({ + instanceId: alertId, + actionGroup: activeAlerts[alertId].getScheduledActionOptions()?.actionGroup, })) )}` ); } - if (recoveredInstanceIds.length > 0) { + if (recoveredAlertIds.length > 0) { logger.debug( - `alert ${alertLabel} has ${ - recoveredInstanceIds.length - } recovered alert instances: ${JSON.stringify(recoveredInstanceIds)}` + `rule ${ruleLabel} has ${recoveredAlertIds.length} recovered alerts: ${JSON.stringify( + recoveredAlertIds + )}` ); } } /** - * If an error is thrown, wrap it in an AlertTaskRunResult + * If an error is thrown, wrap it in an RuleTaskRunResult * so that we can treat each field independantly */ -async function errorAsAlertTaskRunResult( - future: Promise> -): Promise> { +async function errorAsRuleTaskRunResult( + future: Promise> +): Promise> { try { return await future; } catch (e) { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index c82cc0a7f21e8..1f5730395e79d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -29,10 +29,10 @@ import { alertsMock, rulesClientMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { IEventLogger } from '../../../event_log/server'; import { Alert, RecoveredActionGroup } from '../../common'; -import { UntypedNormalizedAlertType } from '../rule_type_registry'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; -const ruleType: jest.Mocked = { +const ruleType: jest.Mocked = { id: 'test', name: 'My test rule', actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], @@ -100,7 +100,7 @@ describe('Task Runner Cancel', () => { ruleTypeRegistry, kibanaBaseUrl: 'https://localhost:5601', supportsEphemeralTasks: false, - maxEphemeralActionsPerAlert: 10, + maxEphemeralActionsPerRule: 10, cancelAlertsOnRuleTimeout: true, }; @@ -196,7 +196,6 @@ describe('Task Runner Cancel', () => { expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-start', category: ['alerts'], @@ -216,7 +215,7 @@ describe('Task Runner Cancel', () => { scheduled: '1970-01-01T00:00:00.000Z', }, }, - message: 'alert execution start: "1"', + message: 'rule execution start: "1"', rule: { category: 'test', id: '1', @@ -225,7 +224,6 @@ describe('Task Runner Cancel', () => { }, }); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-timeout', category: ['alerts'], @@ -250,7 +248,6 @@ describe('Task Runner Cancel', () => { }, }); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute', category: ['alerts'], @@ -274,7 +271,7 @@ describe('Task Runner Cancel', () => { scheduled: '1970-01-01T00:00:00.000Z', }, }, - message: `alert executed: test:1: 'rule-name'`, + message: `rule executed: test:1: 'rule-name'`, rule: { category: 'test', id: '1', @@ -398,7 +395,7 @@ describe('Task Runner Cancel', () => { const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(6); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, `Cancelling rule type test with id 1 - execution exceeded rule type timeout of 5m` @@ -409,22 +406,21 @@ describe('Task Runner Cancel', () => { ); expect(logger.debug).nthCalledWith( 4, - `alert test:1: 'rule-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:1: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 5, - `no scheduling of actions for alert test:1: 'rule-name': alert execution has been cancelled.` + `no scheduling of actions for rule test:1: 'rule-name': rule execution has been cancelled.` ); expect(logger.debug).nthCalledWith( 6, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-start', category: ['alerts'], @@ -444,7 +440,7 @@ describe('Task Runner Cancel', () => { }, ], }, - message: `alert execution start: \"1\"`, + message: `rule execution start: \"1\"`, rule: { category: 'test', id: '1', @@ -453,7 +449,6 @@ describe('Task Runner Cancel', () => { }, }); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-timeout', category: ['alerts'], @@ -479,7 +474,6 @@ describe('Task Runner Cancel', () => { }, }); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute', category: ['alerts'], @@ -504,7 +498,7 @@ describe('Task Runner Cancel', () => { }, ], }, - message: "alert executed: test:1: 'rule-name'", + message: "rule executed: test:1: 'rule-name'", rule: { category: 'test', id: '1', @@ -518,7 +512,7 @@ describe('Task Runner Cancel', () => { function testActionsExecute() { const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(5); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, `Cancelling rule type test with id 1 - execution exceeded rule type timeout of 5m` @@ -529,17 +523,16 @@ describe('Task Runner Cancel', () => { ); expect(logger.debug).nthCalledWith( 4, - `alert test:1: 'rule-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:1: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 5, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'ruleExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-start', category: ['alerts'], @@ -560,7 +553,7 @@ describe('Task Runner Cancel', () => { }, ], }, - message: `alert execution start: "1"`, + message: `rule execution start: "1"`, rule: { category: 'test', id: '1', @@ -569,7 +562,6 @@ describe('Task Runner Cancel', () => { }, }); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute-timeout', category: ['alerts'], @@ -617,7 +609,7 @@ describe('Task Runner Cancel', () => { }, ], }, - message: "test:1: 'rule-name' created new instance: '1'", + message: "test:1: 'rule-name' created new alert: '1'", rule: { category: 'test', id: '1', @@ -644,7 +636,7 @@ describe('Task Runner Cancel', () => { { id: '1', namespace: undefined, rel: 'primary', type: 'alert', type_id: 'test' }, ], }, - message: "test:1: 'rule-name' active instance: '1' in actionGroup: 'default'", + message: "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", rule: { category: 'test', id: '1', @@ -689,7 +681,6 @@ describe('Task Runner Cancel', () => { }, }); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(6, { - '@timestamp': '1970-01-01T00:00:00.000Z', event: { action: 'execute', category: ['alerts'], kind: 'alert', outcome: 'success' }, kibana: { alerting: { @@ -709,7 +700,7 @@ describe('Task Runner Cancel', () => { }, ], }, - message: "alert executed: test:1: 'rule-name'", + message: "rule executed: test:1: 'rule-name'", rule: { category: 'test', id: '1', diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index b799dd2f4043d..038eecda349a1 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -17,13 +17,13 @@ import { import { actionsMock } from '../../../actions/server/mocks'; import { alertsMock, rulesClientMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; -import { UntypedNormalizedAlertType } from '../rule_type_registry'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; import { executionContextServiceMock } from '../../../../../src/core/server/mocks'; const executionContext = executionContextServiceMock.createSetupContract(); -const alertType: UntypedNormalizedAlertType = { +const ruleType: UntypedNormalizedRuleType = { id: 'test', name: 'My test alert', actionGroups: [{ id: 'default', name: 'Default' }], @@ -83,7 +83,7 @@ describe('Task Runner Factory', () => { ruleTypeRegistry: ruleTypeRegistryMock.create(), kibanaBaseUrl: 'https://localhost:5601', supportsEphemeralTasks: true, - maxEphemeralActionsPerAlert: 10, + maxEphemeralActionsPerRule: 10, cancelAlertsOnRuleTimeout: true, executionContext, }; @@ -96,7 +96,7 @@ describe('Task Runner Factory', () => { test(`throws an error if factory isn't initialized`, () => { const factory = new TaskRunnerFactory(); expect(() => - factory.create(alertType, { taskInstance: mockedTaskInstance }) + factory.create(ruleType, { taskInstance: mockedTaskInstance }) ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`); }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index fc4b8eee89f5e..69c8ff471c8bb 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -28,7 +28,7 @@ import { import { TaskRunner } from './task_runner'; import { IEventLogger } from '../../../event_log/server'; import { RulesClient } from '../rules_client'; -import { NormalizedAlertType } from '../rule_type_registry'; +import { NormalizedRuleType } from '../rule_type_registry'; export interface TaskRunnerContext { logger: Logger; @@ -44,7 +44,7 @@ export interface TaskRunnerContext { ruleTypeRegistry: RuleTypeRegistry; kibanaBaseUrl: string | undefined; supportsEphemeralTasks: boolean; - maxEphemeralActionsPerAlert: number; + maxEphemeralActionsPerRule: number; cancelAlertsOnRuleTimeout: boolean; } @@ -69,7 +69,7 @@ export class TaskRunnerFactory { ActionGroupIds extends string, RecoveryActionGroupId extends string >( - alertType: NormalizedAlertType< + ruleType: NormalizedRuleType< Params, ExtractedParams, State, @@ -92,6 +92,6 @@ export class TaskRunnerFactory { InstanceContext, ActionGroupIds, RecoveryActionGroupId - >(alertType, taskInstance, this.taskRunnerContext!); + >(ruleType, taskInstance, this.taskRunnerContext!); } } diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 343b717dcb1aa..6671810a0b738 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -118,7 +118,7 @@ export type ExecutorType< export interface AlertTypeParamsValidator { validate: (object: unknown) => Params; } -export interface AlertType< +export interface RuleType< Params extends AlertTypeParams = never, ExtractedParams extends AlertTypeParams = never, State extends AlertTypeState = never, @@ -163,7 +163,7 @@ export interface AlertType< ruleTaskTimeout?: string; cancelAlertsOnRuleTimeout?: boolean; } -export type UntypedAlertType = AlertType< +export type UntypedRuleType = RuleType< AlertTypeParams, AlertTypeState, AlertInstanceState, @@ -184,7 +184,7 @@ export interface AlertMeta extends SavedObjectAttributes { // note that the `error` property is "null-able", as we're doing a partial // update on the alert when we update this data, but need to ensure we // delete any previous error if the current status has no error -export interface RawAlertExecutionStatus extends SavedObjectAttributes { +export interface RawRuleExecutionStatus extends SavedObjectAttributes { status: AlertExecutionStatuses; lastExecutionDate: string; lastDuration?: number; @@ -201,7 +201,7 @@ export interface AlertWithLegacyId exten legacyId: string | null; } -export type SanitizedAlertWithLegacyId = Omit< +export type SanitizedRuleWithLegacyId = Omit< AlertWithLegacyId, 'apiKey' >; @@ -212,11 +212,11 @@ export type PartialAlertWithLegacyId = P > & Partial, 'id'>>; -export interface RawAlert extends SavedObjectAttributes { +export interface RawRule extends SavedObjectAttributes { enabled: boolean; name: string; tags: string[]; - alertTypeId: string; + alertTypeId: string; // this cannot be renamed since it is in the saved object consumer: string; legacyId: string | null; schedule: SavedObjectAttributes; @@ -234,11 +234,11 @@ export interface RawAlert extends SavedObjectAttributes { muteAll: boolean; mutedInstanceIds: string[]; meta?: AlertMeta; - executionStatus: RawAlertExecutionStatus; + executionStatus: RawRuleExecutionStatus; } export type AlertInfoParams = Pick< - RawAlert, + RawRule, | 'params' | 'throttle' | 'notifyWhen' 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 6da21bf2bf2c7..5dd3588674179 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 @@ -73,11 +73,11 @@ Object { } `; -exports[`Error HOST_NAME 1`] = `"my hostname"`; +exports[`Error HOST_HOSTNAME 1`] = `"my hostname"`; -exports[`Error HOST_OS_PLATFORM 1`] = `undefined`; +exports[`Error HOST_NAME 1`] = `undefined`; -exports[`Error HOSTNAME 1`] = `undefined`; +exports[`Error HOST_OS_PLATFORM 1`] = `undefined`; exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`; @@ -314,12 +314,12 @@ exports[`Span FID_FIELD 1`] = `undefined`; exports[`Span HOST 1`] = `undefined`; +exports[`Span HOST_HOSTNAME 1`] = `undefined`; + exports[`Span HOST_NAME 1`] = `undefined`; exports[`Span HOST_OS_PLATFORM 1`] = `undefined`; -exports[`Span HOSTNAME 1`] = `undefined`; - exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`; exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; @@ -555,11 +555,11 @@ Object { } `; -exports[`Transaction HOST_NAME 1`] = `"my hostname"`; +exports[`Transaction HOST_HOSTNAME 1`] = `"my hostname"`; -exports[`Transaction HOST_OS_PLATFORM 1`] = `undefined`; +exports[`Transaction HOST_NAME 1`] = `undefined`; -exports[`Transaction HOSTNAME 1`] = `undefined`; +exports[`Transaction HOST_OS_PLATFORM 1`] = `undefined`; exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`; diff --git a/x-pack/plugins/apm/common/correlations/field_stats_types.ts b/x-pack/plugins/apm/common/correlations/field_stats_types.ts index 50dc7919fbd00..41f7e3c3c6649 100644 --- a/x-pack/plugins/apm/common/correlations/field_stats_types.ts +++ b/x-pack/plugins/apm/common/correlations/field_stats_types.ts @@ -8,9 +8,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { CorrelationsParams } from './types'; -export interface FieldStatsCommonRequestParams extends CorrelationsParams { - samplerShardSize: number; -} +export type FieldStatsCommonRequestParams = CorrelationsParams; export interface Field { fieldName: string; @@ -55,3 +53,5 @@ export type FieldStats = | NumericFieldStats | KeywordFieldStats | BooleanFieldStats; + +export type FieldValueFieldStats = TopValuesStats; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index b42c23ee2df94..5c7c953d8d900 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -113,8 +113,8 @@ export const METRICSET_NAME = 'metricset.name'; export const LABEL_NAME = 'labels.name'; export const HOST = 'host'; -export const HOST_NAME = 'host.hostname'; -export const HOSTNAME = 'host.name'; +export const HOST_HOSTNAME = 'host.hostname'; // Do not use. Please use `HOST_NAME` instead. +export const HOST_NAME = 'host.name'; export const HOST_OS_PLATFORM = 'host.os.platform'; export const CONTAINER_ID = 'container.id'; export const KUBERNETES = 'kubernetes'; diff --git a/x-pack/plugins/apm/common/fleet.ts b/x-pack/plugins/apm/common/fleet.ts index 00a958952d2de..bd8c6cf2653c2 100644 --- a/x-pack/plugins/apm/common/fleet.ts +++ b/x-pack/plugins/apm/common/fleet.ts @@ -8,7 +8,7 @@ import semverParse from 'semver/functions/parse'; export const POLICY_ELASTIC_AGENT_ON_CLOUD = 'policy-elastic-agent-on-cloud'; -export const SUPPORTED_APM_PACKAGE_VERSION = '7.16.0'; +export const SUPPORTED_APM_PACKAGE_VERSION = '8.0.0-dev4'; // TODO update to just '8.0.0' once published export function isPrereleaseVersion(version: string) { return semverParse(version)?.prerelease?.length ?? 0 > 0; diff --git a/x-pack/plugins/apm/common/privilege_type.ts b/x-pack/plugins/apm/common/privilege_type.ts new file mode 100644 index 0000000000000..e5a67d2a807f0 --- /dev/null +++ b/x-pack/plugins/apm/common/privilege_type.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; + +export const enum PrivilegeType { + SOURCEMAP = 'sourcemap:write', + EVENT = 'event:write', + AGENT_CONFIG = 'config_agent:read', +} + +export const privilegesTypeRt = t.array( + t.union([ + t.literal(PrivilegeType.SOURCEMAP), + t.literal(PrivilegeType.EVENT), + t.literal(PrivilegeType.AGENT_CONFIG), + ]) +); diff --git a/x-pack/plugins/apm/public/application/uxApp.tsx b/x-pack/plugins/apm/public/application/uxApp.tsx index f7a8f7030d4f9..cfb1a5c354c2d 100644 --- a/x-pack/plugins/apm/public/application/uxApp.tsx +++ b/x-pack/plugins/apm/public/application/uxApp.tsx @@ -130,7 +130,6 @@ export function UXAppRoot({ services={{ ...core, ...plugins, embeddable, data }} > - {/* @ts-expect-error Type instantiation is excessively deep */} diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/agent_keys_table.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/agent_keys_table.tsx index f49264242e63f..ccd409f1798a5 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/agent_keys_table.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/agent_keys_table.tsx @@ -82,7 +82,7 @@ export function AgentKeysTable({ agentKeys, onKeyDelete }: Props) { description: i18n.translate( 'xpack.apm.settings.agentKeys.table.deleteActionDescription', { - defaultMessage: 'Delete this agent key', + defaultMessage: 'Delete this APM agent key', } ), icon: 'trash', @@ -144,7 +144,7 @@ export function AgentKeysTable({ agentKeys, onKeyDelete }: Props) { tableCaption={i18n.translate( 'xpack.apm.settings.agentKeys.tableCaption', { - defaultMessage: 'Agent keys', + defaultMessage: 'APM agent keys', } )} items={agentKeys ?? []} diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/confirm_delete_modal.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/confirm_delete_modal.tsx index 6125a238f11aa..1fb18499641d9 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/confirm_delete_modal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/confirm_delete_modal.tsx @@ -34,14 +34,14 @@ export function ConfirmDeleteModal({ agentKey, onCancel, onConfirm }: Props) { }); toasts.addSuccess( i18n.translate('xpack.apm.settings.agentKeys.invalidate.succeeded', { - defaultMessage: 'Deleted agent key "{name}"', + defaultMessage: 'Deleted APM agent key "{name}"', values: { name }, }) ); } catch (error) { toasts.addDanger( i18n.translate('xpack.apm.settings.agentKeys.invalidate.failed', { - defaultMessage: 'Error deleting agent key "{name}"', + defaultMessage: 'Error deleting APM agent key "{name}"', values: { name }, }) ); @@ -53,7 +53,7 @@ export function ConfirmDeleteModal({ agentKey, onCancel, onConfirm }: Props) { title={i18n.translate( 'xpack.apm.settings.agentKeys.deleteConfirmModal.title', { - defaultMessage: 'Delete agent key "{name}"?', + defaultMessage: 'Delete APM agent key "{name}"?', values: { name }, } )} diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key.tsx index 5803e5a2a75a8..01b5bfe907bbc 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, @@ -28,30 +28,31 @@ import { } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { ApmPluginStartDeps } from '../../../../plugin'; import { CreateApiKeyResponse } from '../../../../../common/agent_key_types'; +import { useCurrentUser } from '../../../../hooks/use_current_user'; +import { PrivilegeType } from '../../../../../common/privilege_type'; interface Props { onCancel: () => void; onSuccess: (agentKey: CreateApiKeyResponse) => void; - onError: (keyName: string) => void; + onError: (keyName: string, message: string) => void; } export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) { - const { - services: { security }, - } = useKibana(); + const [formTouched, setFormTouched] = useState(false); - const [username, setUsername] = useState(''); + const [agentKeyBody, setAgentKeyBody] = useState({ + name: '', + sourcemap: true, + event: true, + agentConfig: true, + }); - const [formTouched, setFormTouched] = useState(false); - const [keyName, setKeyName] = useState(''); - const [agentConfigChecked, setAgentConfigChecked] = useState(true); - const [eventWriteChecked, setEventWriteChecked] = useState(true); - const [sourcemapChecked, setSourcemapChecked] = useState(true); + const { name, sourcemap, event, agentConfig } = agentKeyBody; - const isInputInvalid = isEmpty(keyName); + const currentUser = useCurrentUser(); + + const isInputInvalid = isEmpty(name); const isFormInvalid = formTouched && isInputInvalid; const formError = i18n.translate( @@ -59,21 +60,9 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) { { defaultMessage: 'Enter a name' } ); - useEffect(() => { - const getCurrentUser = async () => { - try { - const authenticatedUser = await security?.authc.getCurrentUser(); - setUsername(authenticatedUser?.username || ''); - } catch { - setUsername(''); - } - }; - getCurrentUser(); - }, [security?.authc]); - const createAgentKeyTitle = i18n.translate( 'xpack.apm.settings.agentKeys.createKeyFlyout.createAgentKey', - { defaultMessage: 'Create agent key' } + { defaultMessage: 'Create APM agent key' } ); const createAgentKey = async () => { @@ -83,22 +72,33 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) { } try { + const privileges: PrivilegeType[] = []; + if (sourcemap) { + privileges.push(PrivilegeType.SOURCEMAP); + } + + if (event) { + privileges.push(PrivilegeType.EVENT); + } + + if (agentConfig) { + privileges.push(PrivilegeType.AGENT_CONFIG); + } + const { agentKey } = await callApmApi({ - endpoint: 'POST /apm/agent_keys', + endpoint: 'POST /api/apm/agent_keys', signal: null, params: { body: { - name: keyName, - sourcemap: sourcemapChecked, - event: eventWriteChecked, - agentConfig: agentConfigChecked, + name, + privileges, }, }, }); onSuccess(agentKey); } catch (error) { - onError(keyName); + onError(name, error.body?.message || error.message); } }; @@ -112,14 +112,14 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) { - {username && ( + {currentUser && ( - {username} + {currentUser?.username} )} setKeyName(e.target.value)} + onChange={(e) => + setAgentKeyBody((state) => ({ ...state, name: e.target.value })) + } isInvalid={isFormInvalid} onBlur={() => setFormTouched(true)} /> @@ -174,8 +176,13 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) { setAgentConfigChecked((state) => !state)} + checked={agentConfig} + onChange={() => + setAgentKeyBody((state) => ({ + ...state, + agentConfig: !state.agentConfig, + })) + } /> @@ -190,8 +197,13 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) { setEventWriteChecked((state) => !state)} + checked={event} + onChange={() => + setAgentKeyBody((state) => ({ + ...state, + event: !state.event, + })) + } /> @@ -206,8 +218,13 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) { setSourcemapChecked((state) => !state)} + checked={sourcemap} + onChange={() => + setAgentKeyBody((state) => ({ + ...state, + sourcemap: !state.sourcemap, + })) + } /> diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key/agent_key_callout.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key/agent_key_callout.tsx index db313e35a0229..a96446ce2a2b3 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key/agent_key_callout.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/create_agent_key/agent_key_callout.tsx @@ -12,7 +12,7 @@ import { EuiCallOut, EuiButtonIcon, EuiCopy, - EuiFormControlLayout, + EuiFieldText, } from '@elastic/eui'; interface Props { @@ -43,9 +43,15 @@ export function AgentKeyCallOut({ name, token }: Props) { } )}

- @@ -65,20 +71,7 @@ export function AgentKeyCallOut({ name, token }: Props) { )} } - > - - + /> diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/index.tsx index 8fb4ede96a819..3305f05dd90f9 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_keys/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_keys/index.tsx @@ -75,7 +75,7 @@ export function AgentKeys() { {i18n.translate('xpack.apm.settings.agentKeys.descriptionText', { defaultMessage: - 'View and delete agent keys. An agent key sends requests on behalf of a user.', + 'View and delete APM agent keys. An APM agent key sends requests on behalf of a user.', })} @@ -84,7 +84,7 @@ export function AgentKeys() {

{i18n.translate('xpack.apm.settings.agentKeys.title', { - defaultMessage: 'Agent keys', + defaultMessage: 'APM agent keys', })}

@@ -99,7 +99,7 @@ export function AgentKeys() { {i18n.translate( 'xpack.apm.settings.agentKeys.createAgentKeyButton', { - defaultMessage: 'Create agent key', + defaultMessage: 'Create APM agent key', } )} @@ -123,11 +123,12 @@ export function AgentKeys() { setIsFlyoutVisible(false); refetchAgentKeys(); }} - onError={(keyName: string) => { + onError={(keyName: string, message: string) => { toasts.addDanger( i18n.translate('xpack.apm.settings.agentKeys.crate.failed', { - defaultMessage: 'Error creating agent key "{keyName}"', - values: { keyName }, + defaultMessage: + 'Error creating APM agent key "{keyName}". Error: "{message}"', + values: { keyName, message }, }) ); setIsFlyoutVisible(false); @@ -184,7 +185,7 @@ function AgentKeysContent({ {i18n.translate( 'xpack.apm.settings.agentKeys.agentKeysLoadingPromptTitle', { - defaultMessage: 'Loading Agent keys...', + defaultMessage: 'Loading APM agent keys...', } )} @@ -202,7 +203,7 @@ function AgentKeysContent({ {i18n.translate( 'xpack.apm.settings.agentKeys.agentKeysErrorPromptTitle', { - defaultMessage: 'Could not load agent keys.', + defaultMessage: 'Could not load APM agent keys.', } )} @@ -235,7 +236,7 @@ function AgentKeysContent({

{i18n.translate('xpack.apm.settings.agentKeys.emptyPromptBody', { defaultMessage: - 'Create keys to authorize agent requests to the APM Server.', + 'Create APM agent keys to authorize APM agent requests to the APM Server.', })}

} @@ -248,7 +249,7 @@ function AgentKeysContent({ {i18n.translate( 'xpack.apm.settings.agentKeys.createAgentKeyButton', { - defaultMessage: 'Create agent key', + defaultMessage: 'Create APM agent key', } )} diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx b/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx index f1d0d194749c5..d7043ea669a03 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx @@ -11,14 +11,11 @@ import { EuiFlexItem, EuiPopover, EuiPopoverTitle, - EuiSpacer, - EuiText, EuiTitle, EuiToolTip, } from '@elastic/eui'; -import React, { Fragment, useState } from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { FieldStats } from '../../../../../common/correlations/field_stats_types'; import { OnAddFilter, TopValues } from './top_values'; import { useTheme } from '../../../../hooks/use_theme'; @@ -97,27 +94,11 @@ export function CorrelationsContextPopover({ {infoIsOpen ? ( - <> - - {topValueStats.topValuesSampleSize !== undefined && ( - - - - - - - )} - + ) : null} ); diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx b/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx index 05b4f6d56fa45..fbf33899a2de2 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx @@ -12,11 +12,21 @@ import { EuiProgress, EuiSpacer, EuiToolTip, + EuiText, + EuiHorizontalRule, + EuiLoadingSpinner, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FieldStats } from '../../../../../common/correlations/field_stats_types'; +import numeral from '@elastic/numeral'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + FieldStats, + TopValueBucket, +} from '../../../../../common/correlations/field_stats_types'; import { asPercent } from '../../../../../common/utils/formatters'; import { useTheme } from '../../../../hooks/use_theme'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useFetchParams } from '../use_fetch_params'; export type OnAddFilter = ({ fieldName, @@ -28,23 +38,179 @@ export type OnAddFilter = ({ include: boolean; }) => void; -interface Props { +interface TopValueProps { + progressBarMax: number; + barColor: string; + value: TopValueBucket; + isHighlighted: boolean; + fieldName: string; + onAddFilter?: OnAddFilter; + valueText?: string; + reverseLabel?: boolean; +} +export function TopValue({ + progressBarMax, + barColor, + value, + isHighlighted, + fieldName, + onAddFilter, + valueText, + reverseLabel = false, +}: TopValueProps) { + const theme = useTheme(); + return ( + + + + {value.key} + + } + className="eui-textTruncate" + aria-label={value.key.toString()} + valueText={valueText} + labelProps={ + isHighlighted + ? { + style: { fontWeight: 'bold' }, + } + : undefined + } + /> + + {fieldName !== undefined && + value.key !== undefined && + onAddFilter !== undefined ? ( + <> + { + onAddFilter({ + fieldName, + fieldValue: + typeof value.key === 'number' + ? value.key.toString() + : value.key, + include: true, + }); + }} + aria-label={i18n.translate( + 'xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel', + { + defaultMessage: 'Filter for {fieldName}: "{value}"', + values: { fieldName, value: value.key }, + } + )} + data-test-subj={`apmFieldContextTopValuesAddFilterButton-${value.key}-${value.key}`} + style={{ + minHeight: 'auto', + width: theme.eui.euiSizeL, + paddingRight: 2, + paddingLeft: 2, + paddingTop: 0, + paddingBottom: 0, + }} + /> + { + onAddFilter({ + fieldName, + fieldValue: + typeof value.key === 'number' + ? value.key.toString() + : value.key, + include: false, + }); + }} + aria-label={i18n.translate( + 'xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel', + { + defaultMessage: 'Filter out {fieldName}: "{value}"', + values: { fieldName, value: value.key }, + } + )} + data-test-subj={`apmFieldContextTopValuesExcludeFilterButton-${value.key}-${value.key}`} + style={{ + minHeight: 'auto', + width: theme.eui.euiSizeL, + paddingTop: 0, + paddingBottom: 0, + paddingRight: 2, + paddingLeft: 2, + }} + /> + + ) : null} + + ); +} + +interface TopValuesProps { topValueStats: FieldStats; compressed?: boolean; onAddFilter?: OnAddFilter; fieldValue?: string | number; } -export function TopValues({ topValueStats, onAddFilter, fieldValue }: Props) { +export function TopValues({ + topValueStats, + onAddFilter, + fieldValue, +}: TopValuesProps) { const { topValues, topValuesSampleSize, count, fieldName } = topValueStats; const theme = useTheme(); - if (!Array.isArray(topValues) || topValues.length === 0) return null; + const idxToHighlight = Array.isArray(topValues) + ? topValues.findIndex((value) => value.key === fieldValue) + : null; + + const params = useFetchParams(); + const { data: fieldValueStats, status } = useFetcher( + (callApmApi) => { + if ( + idxToHighlight === -1 && + fieldName !== undefined && + fieldValue !== undefined + ) { + return callApmApi({ + endpoint: 'GET /internal/apm/correlations/field_value_stats', + params: { + query: { + ...params, + fieldName, + fieldValue, + }, + }, + }); + } + }, + [params, fieldName, fieldValue, idxToHighlight] + ); + if ( + !Array.isArray(topValues) || + topValues?.length === 0 || + fieldValue === undefined + ) + return null; const sampledSize = typeof topValuesSampleSize === 'string' ? parseInt(topValuesSampleSize, 10) : topValuesSampleSize; + const progressBarMax = sampledSize ?? count; return (
- - - - {value.key} - - } - className="eui-textTruncate" - aria-label={value.key.toString()} - valueText={valueText} - labelProps={ - isHighlighted - ? { - style: { fontWeight: 'bold' }, - } - : undefined - } - /> - - {fieldName !== undefined && - value.key !== undefined && - onAddFilter !== undefined ? ( - <> - { - onAddFilter({ - fieldName, - fieldValue: - typeof value.key === 'number' - ? value.key.toString() - : value.key, - include: true, - }); - }} - aria-label={i18n.translate( - 'xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel', - { - defaultMessage: 'Filter for {fieldName}: "{value}"', - values: { fieldName, value: value.key }, - } - )} - data-test-subj={`apmFieldContextTopValuesAddFilterButton-${value.key}-${value.key}`} - style={{ - minHeight: 'auto', - width: theme.eui.euiSizeL, - paddingRight: 2, - paddingLeft: 2, - paddingTop: 0, - paddingBottom: 0, - }} - /> - { - onAddFilter({ - fieldName, - fieldValue: - typeof value.key === 'number' - ? value.key.toString() - : value.key, - include: false, - }); - }} - aria-label={i18n.translate( - 'xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel', - { - defaultMessage: 'Filter out {fieldName}: "{value}"', - values: { fieldName, value: value.key }, - } - )} - data-test-subj={`apmFieldContextTopValuesExcludeFilterButton-${value.key}-${value.key}`} - style={{ - minHeight: 'auto', - width: theme.eui.euiSizeL, - paddingTop: 0, - paddingBottom: 0, - paddingRight: 2, - paddingLeft: 2, - }} - /> - - ) : null} - + ); })} + + {idxToHighlight === -1 && ( + <> + + + + + + {status === FETCH_STATUS.SUCCESS && + Array.isArray(fieldValueStats?.topValues) ? ( + fieldValueStats?.topValues.map((value) => { + const valueText = + progressBarMax !== undefined + ? asPercent(value.doc_count, progressBarMax) + : undefined; + + return ( + + ); + }) + ) : ( + + + + )} + + )} + + {topValueStats.topValuesSampleSize !== undefined && ( + <> + + + {i18n.translate( + 'xpack.apm.correlations.fieldContextPopover.calculatedFromSampleDescription', + { + defaultMessage: + 'Calculated from sample of {sampleSize} documents', + values: { sampleSize: topValueStats.topValuesSampleSize }, + } + )} + + + )}
); } diff --git a/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx b/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx index a2026b0a8abea..a530b950cf061 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx @@ -122,7 +122,7 @@ export function CorrelationsTable({ const loadingText = i18n.translate( 'xpack.apm.correlations.correlationsTable.loadingText', - { defaultMessage: 'Loading' } + { defaultMessage: 'Loading...' } ); const noDataText = i18n.translate( diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 1994d3641ee53..163082cf044cd 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -19,7 +19,7 @@ import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detecti import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useLocalStorage } from '../../../hooks/useLocalStorage'; -import { useApmParams } from '../../../hooks/use_apm_params'; +import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { useUpgradeAssistantHref } from '../../shared/Links/kibana'; @@ -46,9 +46,7 @@ function useServicesFetcher() { const { query: { rangeFrom, rangeTo, environment, kuery }, - } = - // @ts-ignore 4.3.5 upgrade - Type instantiation is excessively deep and possibly infinite. - useApmParams('/services/{serviceName}', '/services'); + } = useAnyOfApmParams('/services/{serviceName}', '/services'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index ea65c837a4177..fe91b14e64e8a 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -66,9 +66,11 @@ export function getServiceColumns({ showTransactionTypeColumn, comparisonData, breakpoints, + showHealthStatusColumn, }: { query: TypeOf['query']; showTransactionTypeColumn: boolean; + showHealthStatusColumn: boolean; breakpoints: Breakpoints; comparisonData?: ServicesDetailedStatisticsAPIResponse; }): Array> { @@ -76,21 +78,25 @@ export function getServiceColumns({ const showWhenSmallOrGreaterThanLarge = isSmall || !isLarge; const showWhenSmallOrGreaterThanXL = isSmall || !isXl; return [ - { - field: 'healthStatus', - name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', { - defaultMessage: 'Health', - }), - width: `${unit * 6}px`, - sortable: true, - render: (_, { healthStatus }) => { - return ( - - ); - }, - }, + ...(showHealthStatusColumn + ? [ + { + field: 'healthStatus', + name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', { + defaultMessage: 'Health', + }), + width: `${unit * 6}px`, + sortable: true, + render: (_, { healthStatus }) => { + return ( + + ); + }, + } as ITableColumn, + ] + : []), { field: 'serviceName', name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', { @@ -248,13 +254,17 @@ export function ServiceList({ showTransactionTypeColumn, comparisonData, breakpoints, + showHealthStatusColumn: displayHealthStatus, }), - [query, showTransactionTypeColumn, comparisonData, breakpoints] + [ + query, + showTransactionTypeColumn, + comparisonData, + breakpoints, + displayHealthStatus, + ] ); - const columns = displayHealthStatus - ? serviceColumns - : serviceColumns.filter((column) => column.field !== 'healthStatus'); const initialSortField = displayHealthStatus ? 'healthStatus' : 'transactionsPerMinute'; @@ -300,7 +310,7 @@ export function ServiceList({ { it('renders empty state', async () => { @@ -29,34 +55,10 @@ describe('ServiceList', () => { }); describe('responsive columns', () => { - const query = { - rangeFrom: 'now-15m', - rangeTo: 'now', - environment: ENVIRONMENT_ALL.value, - kuery: '', - }; - - const service: any = { - serviceName: 'opbeans-python', - agentName: 'python', - transactionsPerMinute: { - value: 86.93333333333334, - timeseries: [], - }, - errorsPerMinute: { - value: 12.6, - timeseries: [], - }, - avgResponseTime: { - value: 91535.42944785276, - timeseries: [], - }, - environments: ['test'], - transactionType: 'request', - }; describe('when small', () => { it('shows environment, transaction type and sparklines', () => { const renderedColumns = getServiceColumns({ + showHealthStatusColumn: true, query, showTransactionTypeColumn: true, breakpoints: { @@ -91,6 +93,7 @@ describe('ServiceList', () => { describe('when Large', () => { it('hides environment, transaction type and sparklines', () => { const renderedColumns = getServiceColumns({ + showHealthStatusColumn: true, query, showTransactionTypeColumn: true, breakpoints: { @@ -114,6 +117,7 @@ describe('ServiceList', () => { describe('when XL', () => { it('hides transaction type', () => { const renderedColumns = getServiceColumns({ + showHealthStatusColumn: true, query, showTransactionTypeColumn: true, breakpoints: { @@ -147,6 +151,7 @@ describe('ServiceList', () => { describe('when XXL', () => { it('hides transaction type', () => { const renderedColumns = getServiceColumns({ + showHealthStatusColumn: true, query, showTransactionTypeColumn: true, breakpoints: { @@ -181,20 +186,34 @@ describe('ServiceList', () => { }); describe('without ML data', () => { - it('sorts by throughput', async () => { - render(); - - expect(await screen.findByTitle('Throughput')).toBeInTheDocument(); + it('hides healthStatus column', () => { + const renderedColumns = getServiceColumns({ + showHealthStatusColumn: false, + query, + showTransactionTypeColumn: true, + breakpoints: { + isSmall: false, + isLarge: false, + isXl: false, + } as Breakpoints, + }).map((c) => c.field); + expect(renderedColumns.includes('healthStatus')).toBeFalsy(); }); }); describe('with ML data', () => { - it('renders the health column', async () => { - render(); - - expect( - await screen.findByRole('button', { name: /Health/ }) - ).toBeInTheDocument(); + it('shows healthStatus column', () => { + const renderedColumns = getServiceColumns({ + showHealthStatusColumn: true, + query, + showTransactionTypeColumn: true, + breakpoints: { + isSmall: false, + isLarge: false, + isXl: false, + } as Breakpoints, + }).map((c) => c.field); + expect(renderedColumns.includes('healthStatus')).toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/service_logs/index.test.ts b/x-pack/plugins/apm/public/components/app/service_logs/index.test.ts index b0cc134778d21..74a49d06d761b 100644 --- a/x-pack/plugins/apm/public/components/app/service_logs/index.test.ts +++ b/x-pack/plugins/apm/public/components/app/service_logs/index.test.ts @@ -7,26 +7,53 @@ import { getInfrastructureKQLFilter } from './'; describe('service logs', () => { + const serviceName = 'opbeans-node'; + describe('getInfrastructureKQLFilter', () => { - it('filter by container id', () => { + it('filter by service name', () => { + expect( + getInfrastructureKQLFilter( + { + serviceInfrastructure: { + containerIds: [], + hostNames: [], + }, + }, + serviceName + ) + ).toEqual('service.name: "opbeans-node"'); + }); + + it('filter by container id as fallback', () => { expect( - getInfrastructureKQLFilter({ - serviceInfrastructure: { - containerIds: ['foo', 'bar'], - hostNames: ['baz', `quz`], + getInfrastructureKQLFilter( + { + serviceInfrastructure: { + containerIds: ['foo', 'bar'], + hostNames: ['baz', `quz`], + }, }, - }) - ).toEqual('container.id: "foo" or container.id: "bar"'); + serviceName + ) + ).toEqual( + 'service.name: "opbeans-node" or (not service.name and (container.id: "foo" or container.id: "bar"))' + ); }); - it('filter by host names', () => { + + it('filter by host names as fallback', () => { expect( - getInfrastructureKQLFilter({ - serviceInfrastructure: { - containerIds: [], - hostNames: ['baz', `quz`], + getInfrastructureKQLFilter( + { + serviceInfrastructure: { + containerIds: [], + hostNames: ['baz', `quz`], + }, }, - }) - ).toEqual('host.name: "baz" or host.name: "quz"'); + serviceName + ) + ).toEqual( + 'service.name: "opbeans-node" or (not service.name and (host.name: "baz" or host.name: "quz"))' + ); }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/service_logs/index.tsx b/x-pack/plugins/apm/public/components/app/service_logs/index.tsx index bb32919196f84..4f1c517d14b26 100644 --- a/x-pack/plugins/apm/public/components/app/service_logs/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_logs/index.tsx @@ -17,7 +17,8 @@ import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { CONTAINER_ID, - HOSTNAME, + HOST_NAME, + SERVICE_NAME, } from '../../../../common/elasticsearch_fieldnames'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useTimeRange } from '../../../hooks/use_time_range'; @@ -86,20 +87,27 @@ export function ServiceLogs() { height={'60vh'} startTimestamp={moment(start).valueOf()} endTimestamp={moment(end).valueOf()} - query={getInfrastructureKQLFilter(data)} + query={getInfrastructureKQLFilter(data, serviceName)} /> ); } export const getInfrastructureKQLFilter = ( - data?: APIReturnType<'GET /internal/apm/services/{serviceName}/infrastructure'> + data: + | APIReturnType<'GET /internal/apm/services/{serviceName}/infrastructure'> + | undefined, + serviceName: string ) => { const containerIds = data?.serviceInfrastructure?.containerIds ?? []; const hostNames = data?.serviceInfrastructure?.hostNames ?? []; - const kqlFilter = containerIds.length + const infraAttributes = containerIds.length ? containerIds.map((id) => `${CONTAINER_ID}: "${id}"`) - : hostNames.map((id) => `${HOSTNAME}: "${id}"`); + : hostNames.map((id) => `${HOST_NAME}: "${id}"`); - return kqlFilter.join(' or '); + const infraAttributesJoined = infraAttributes.join(' or '); + + return infraAttributes.length + ? `${SERVICE_NAME}: "${serviceName}" or (not ${SERVICE_NAME} and (${infraAttributesJoined}))` + : `${SERVICE_NAME}: "${serviceName}"`; }; diff --git a/x-pack/plugins/apm/public/components/app/service_map/Controls.tsx b/x-pack/plugins/apm/public/components/app/service_map/Controls.tsx index 4605952a6f396..a48fb77b45585 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Controls.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Controls.tsx @@ -16,7 +16,7 @@ import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_ import { APMQueryParams } from '../../shared/Links/url_helpers'; import { CytoscapeContext } from './Cytoscape'; import { getAnimationOptions, getNodeHeight } from './cytoscape_options'; -import { useApmParams } from '../../../hooks/use_apm_params'; +import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; const ControlsContainer = euiStyled('div')` left: ${({ theme }) => theme.eui.gutterTypes.gutterMedium}; @@ -107,7 +107,7 @@ export function Controls() { const { query: { kuery }, - } = useApmParams('/service-map', '/services/{serviceName}/service-map'); + } = useAnyOfApmParams('/service-map', '/services/{serviceName}/service-map'); const [zoom, setZoom] = useState((cy && cy.zoom()) || 1); const duration = parseInt(theme.eui.euiAnimSpeedFast, 10); diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx index a862ff872f61a..a1e4cbe67e65c 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/backend_contents.tsx @@ -12,12 +12,20 @@ import { METRIC_TYPE } from '@kbn/analytics'; import React from 'react'; import { useUiTracker } from '../../../../../../observability/public'; import { ContentsProps } from '.'; -import { NodeStats } from '../../../../../common/service_map'; -import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useAnyOfApmParams } from '../../../../hooks/use_apm_params'; import { useApmRouter } from '../../../../hooks/use_apm_router'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; import { ApmRoutes } from '../../../routing/apm_route_config'; import { StatsList } from './stats_list'; +import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; + +type BackendReturn = APIReturnType<'GET /internal/apm/service-map/backend'>; + +const INITIAL_STATE: Partial = { + currentPeriod: undefined, + previousPeriod: undefined, +}; export function BackendContents({ nodeData, @@ -25,17 +33,25 @@ export function BackendContents({ start, end, }: ContentsProps) { - // @ts-ignore 4.3.5 upgrade - Type instantiation is excessively deep and possibly infinite. - const { query } = useApmParams( + const { query } = useAnyOfApmParams( '/service-map', '/services/{serviceName}/service-map' ); + const { comparisonEnabled, comparisonType } = query; + + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonEnabled, + comparisonType, + }); + const apmRouter = useApmRouter(); const backendName = nodeData.label; - const { data = { transactionStats: {} } as NodeStats, status } = useFetcher( + const { data = INITIAL_STATE, status } = useFetcher( (callApmApi) => { if (backendName) { return callApmApi({ @@ -46,15 +62,13 @@ export function BackendContents({ environment, start, end, + offset, }, }, }); } }, - [environment, backendName, start, end], - { - preservePreviousData: false, - } + [environment, backendName, start, end, offset] ); const isLoading = status === FETCH_STATUS.LOADING; diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/service_contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_contents.tsx index 10d558e648376..b0ca933e64819 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/service_contents.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_contents.tsx @@ -17,12 +17,21 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useApmParams } from '../../../../hooks/use_apm_params'; import type { ContentsProps } from '.'; -import { NodeStats } from '../../../../../common/service_map'; import { useApmRouter } from '../../../../hooks/use_apm_router'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; import { AnomalyDetection } from './anomaly_detection'; import { StatsList } from './stats_list'; import { useTimeRange } from '../../../../hooks/use_time_range'; +import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; + +type ServiceNodeReturn = + APIReturnType<'GET /internal/apm/service-map/service/{serviceName}'>; + +const INITIAL_STATE: ServiceNodeReturn = { + currentPeriod: {}, + previousPeriod: undefined, +}; export function ServiceContents({ onFocusClick, @@ -42,28 +51,32 @@ export function ServiceContents({ throw new Error('Expected rangeFrom and rangeTo to be set'); } - const { rangeFrom, rangeTo } = query; + const { rangeFrom, rangeTo, comparisonEnabled, comparisonType } = query; const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonEnabled, + comparisonType, + }); + const serviceName = nodeData.id!; - const { data = { transactionStats: {} } as NodeStats, status } = useFetcher( + const { data = INITIAL_STATE, status } = useFetcher( (callApmApi) => { if (serviceName && start && end) { return callApmApi({ endpoint: 'GET /internal/apm/service-map/service/{serviceName}', params: { path: { serviceName }, - query: { environment, start, end }, + query: { environment, start, end, offset }, }, }); } }, - [environment, serviceName, start, end], - { - preservePreviousData: false, - } + [environment, serviceName, start, end, offset] ); const isLoading = status === FETCH_STATUS.LOADING; diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/stats_list.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/stats_list.tsx index 002c480503454..1b8e1f64859f4 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/stats_list.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/stats_list.tsx @@ -14,15 +14,18 @@ import { import { i18n } from '@kbn/i18n'; import { isNumber } from 'lodash'; import React, { useMemo } from 'react'; -import { NodeStats } from '../../../../../common/service_map'; import { asDuration, asPercent, asTransactionRate, } from '../../../../../common/utils/formatters'; import { Coordinate } from '../../../../../typings/timeseries'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { SparkPlot, Color } from '../../../shared/charts/spark_plot'; +type ServiceNodeReturn = + APIReturnType<'GET /internal/apm/service-map/service/{serviceName}'>; + function LoadingSpinner() { return ( ; } interface Item { title: string; valueLabel: string | null; timeseries?: Coordinate[]; + previousPeriodTimeseries?: Coordinate[]; color: Color; } export function StatsList({ data, isLoading }: StatsListProps) { + const { currentPeriod = {}, previousPeriod } = data; const { cpuUsage, failedTransactionsRate, memoryUsage, transactionStats } = - data; + currentPeriod; const hasData = [ cpuUsage?.value, @@ -78,10 +83,10 @@ export function StatsList({ data, isLoading }: StatsListProps) { defaultMessage: 'Latency (avg.)', } ), - valueLabel: isNumber(transactionStats?.latency?.value) - ? asDuration(transactionStats?.latency?.value) - : null, - timeseries: transactionStats?.latency?.timeseries, + valueLabel: asDuration(currentPeriod?.transactionStats?.latency?.value), + timeseries: currentPeriod?.transactionStats?.latency?.timeseries, + previousPeriodTimeseries: + previousPeriod?.transactionStats?.latency?.timeseries, color: 'euiColorVis1', }, { @@ -91,24 +96,35 @@ export function StatsList({ data, isLoading }: StatsListProps) { defaultMessage: 'Throughput (avg.)', } ), - valueLabel: asTransactionRate(transactionStats?.throughput?.value), - timeseries: transactionStats?.throughput?.timeseries, + valueLabel: asTransactionRate( + currentPeriod?.transactionStats?.throughput?.value + ), + timeseries: currentPeriod?.transactionStats?.throughput?.timeseries, + previousPeriodTimeseries: + previousPeriod?.transactionStats?.throughput?.timeseries, color: 'euiColorVis0', }, { title: i18n.translate('xpack.apm.serviceMap.errorRatePopoverStat', { defaultMessage: 'Failed transaction rate (avg.)', }), - valueLabel: asPercent(failedTransactionsRate?.value, 1, ''), - timeseries: failedTransactionsRate?.timeseries, + valueLabel: asPercent( + currentPeriod?.failedTransactionsRate?.value, + 1, + '' + ), + timeseries: currentPeriod?.failedTransactionsRate?.timeseries, + previousPeriodTimeseries: + previousPeriod?.failedTransactionsRate?.timeseries, color: 'euiColorVis7', }, { title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverStat', { defaultMessage: 'CPU usage (avg.)', }), - valueLabel: asPercent(cpuUsage?.value, 1, ''), - timeseries: cpuUsage?.timeseries, + valueLabel: asPercent(currentPeriod?.cpuUsage?.value, 1, ''), + timeseries: currentPeriod?.cpuUsage?.timeseries, + previousPeriodTimeseries: previousPeriod?.cpuUsage?.timeseries, color: 'euiColorVis3', }, { @@ -118,15 +134,16 @@ export function StatsList({ data, isLoading }: StatsListProps) { defaultMessage: 'Memory usage (avg.)', } ), - valueLabel: asPercent(memoryUsage?.value, 1, ''), - timeseries: memoryUsage?.timeseries, + valueLabel: asPercent(currentPeriod?.memoryUsage?.value, 1, ''), + timeseries: currentPeriod?.memoryUsage?.timeseries, + previousPeriodTimeseries: previousPeriod?.memoryUsage?.timeseries, color: 'euiColorVis8', }, ], - [cpuUsage, failedTransactionsRate, memoryUsage, transactionStats] + [currentPeriod, previousPeriod] ); - if (isLoading) { + if (isLoading && !hasData) { return ; } @@ -136,38 +153,47 @@ export function StatsList({ data, isLoading }: StatsListProps) { return ( - {items.map(({ title, valueLabel, timeseries, color }) => { - if (!valueLabel) { - return null; + {items.map( + ({ + title, + valueLabel, + timeseries, + color, + previousPeriodTimeseries, + }) => { + if (!valueLabel) { + return null; + } + return ( + + + + + {title} + + + + {timeseries ? ( + + ) : ( +
{valueLabel}
+ )} +
+
+
+ ); } - return ( - - - - - {title} - - - - {timeseries ? ( - - ) : ( -
{valueLabel}
- )} -
-
-
- ); - })} + )}
); } diff --git a/x-pack/plugins/apm/public/components/app/service_map/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.tsx index 0ec1e6630003a..ff19029243d07 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.tsx @@ -180,7 +180,7 @@ export function ServiceMap({ return ( <> - +
; } +const StyledEuiAccordion = styled(EuiAccordion)` + // This is an alternative fix suggested by the EUI team to fix drag elements inside EuiAccordion + // This Issue tracks the fix on the Eui side https://github.com/elastic/eui/issues/3548#issuecomment-639041283 + .euiAccordion__childWrapper { + transform: none; + } +`; + export function AgentInstructionsAccordion({ + policy, newPolicy, + onChange, agentName, title, createAgentInstructions, variantId, + AgentRuntimeAttachment, }: Props) { const docLinks = useKibana().services.docLinks; const vars = newPolicy?.inputs?.[0]?.vars; const apmServerUrl = vars?.url.value; const secretToken = vars?.secret_token.value; const steps = createAgentInstructions(apmServerUrl, secretToken); + const stepsElements = steps.map( + ( + { title: stepTitle, textPre, textPost, customComponentName, commands }, + index + ) => { + const commandBlock = commands + ? renderMustache({ + text: commands, + docLinks, + }) + : ''; + + return ( +
+ +

{stepTitle}

+
+ + + {textPre && ( + + )} + {commandBlock && ( + <> + + + {commandBlock} + + + )} + {customComponentName === 'TutorialConfigAgent' && ( + + )} + {customComponentName === 'TutorialConfigAgentRumScript' && ( + + )} + {textPost && ( + <> + + + + )} + + +
+ ); + } + ); + + const manualInstrumentationContent = ( + <> + + {stepsElements} + + ); + return ( - } > - - {steps.map( - ( - { - title: stepTitle, - textPre, - textPost, - customComponentName, - commands, - }, - index - ) => { - const commandBlock = replaceTemplateStrings( - Array.isArray(commands) ? commands.join('\n') : commands || '', - docLinks - ); - return ( -
- -

{stepTitle}

-
- - - {textPre && ( - - )} - {commandBlock && ( - <> - - - {commandBlock} - - - )} - {customComponentName === 'TutorialConfigAgent' && ( - - )} - {customComponentName === 'TutorialConfigAgentRumScript' && ( - - )} - {textPost && ( + {AgentRuntimeAttachment ? ( + <> + + + + {i18n.translate( + 'xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.autoAttachment', + { defaultMessage: 'Auto-Attachment' } + )} + + + + + + ), + content: ( <> - - )} - - -
- ); - } + ), + }, + ]} + /> + + ) : ( + manualInstrumentationContent )} -
+ ); } diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_mappings.ts b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_mappings.ts index 8bfdafe61d44e..5e992094ac64c 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_mappings.ts +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_mappings.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ComponentType } from 'react'; import { createDotNetAgentInstructions, createDjangoAgentInstructions, @@ -18,6 +19,18 @@ import { createRackAgentInstructions, } from '../../../../common/tutorial/instructions/apm_agent_instructions'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { JavaRuntimeAttachment } from './runtime_attachment/supported_agents/java_runtime_attachment'; +import { + NewPackagePolicy, + PackagePolicy, + PackagePolicyEditExtensionComponentProps, +} from '../apm_policy_form/typings'; + +export interface AgentRuntimeAttachmentProps { + policy: PackagePolicy; + newPolicy: NewPackagePolicy; + onChange: PackagePolicyEditExtensionComponentProps['onChange']; +} export type CreateAgentInstructions = ( apmServerUrl?: string, @@ -35,12 +48,14 @@ export const ApmAgentInstructionsMappings: Array<{ title: string; variantId: string; createAgentInstructions: CreateAgentInstructions; + AgentRuntimeAttachment?: ComponentType; }> = [ { agentName: 'java', title: 'Java', variantId: 'java', createAgentInstructions: createJavaAgentInstructions, + AgentRuntimeAttachment: JavaRuntimeAttachment, }, { agentName: 'rum-js', diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/index.tsx index d6a43a1e1268a..09b638fb184df 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/index.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/index.tsx @@ -21,19 +21,28 @@ interface Props { onChange: PackagePolicyEditExtensionComponentProps['onChange']; } -export function ApmAgents({ newPolicy }: Props) { +export function ApmAgents({ policy, newPolicy, onChange }: Props) { return (
{ApmAgentInstructionsMappings.map( - ({ agentName, title, createAgentInstructions, variantId }) => ( + ({ + agentName, + title, + createAgentInstructions, + variantId, + AgentRuntimeAttachment, + }) => ( diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/replace_template_strings.ts b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/render_mustache.ts similarity index 65% rename from x-pack/plugins/apm/public/components/fleet_integration/apm_agents/replace_template_strings.ts rename to x-pack/plugins/apm/public/components/fleet_integration/apm_agents/render_mustache.ts index d36d76d466308..ebf5fea7f2b85 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/replace_template_strings.ts +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/render_mustache.ts @@ -10,12 +10,17 @@ import Mustache from 'mustache'; const TEMPLATE_TAGS = ['{', '}']; -export function replaceTemplateStrings( - text: string, - docLinks?: CoreStart['docLinks'] -) { - Mustache.parse(text, TEMPLATE_TAGS); - return Mustache.render(text, { +export function renderMustache({ + text, + docLinks, +}: { + text: string | string[]; + docLinks?: CoreStart['docLinks']; +}) { + const template = Array.isArray(text) ? text.join('\n') : text; + + Mustache.parse(template, TEMPLATE_TAGS); + return Mustache.render(template, { config: { docs: { base_url: docLinks?.ELASTIC_WEBSITE_URL, diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/default_discovery_rule.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/default_discovery_rule.tsx new file mode 100644 index 0000000000000..848582bb3feb6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/default_discovery_rule.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiBadge, +} from '@elastic/eui'; +import React from 'react'; + +export function DefaultDiscoveryRule() { + return ( + + + + Exclude + + + Everything else + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/discovery_rule.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/discovery_rule.tsx new file mode 100644 index 0000000000000..f7b1b3db3a4c4 --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/discovery_rule.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiBadge, + EuiPanel, + DraggableProvidedDragHandleProps, + EuiButtonIcon, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { Operation } from '.'; + +interface Props { + id: string; + order: number; + operation: string; + type: string; + probe: string; + providedDragHandleProps?: DraggableProvidedDragHandleProps; + onDelete: (discoveryItemId: string) => void; + onEdit: (discoveryItemId: string) => void; + operationTypes: Operation[]; +} + +export function DiscoveryRule({ + id, + order, + operation, + type, + probe, + providedDragHandleProps, + onDelete, + onEdit, + operationTypes, +}: Props) { + const operationTypesLabels = useMemo(() => { + return operationTypes.reduce<{ + [operationValue: string]: { + label: string; + types: { [typeValue: string]: string }; + }; + }>((acc, current) => { + return { + ...acc, + [current.operation.value]: { + label: current.operation.label, + types: current.types.reduce((memo, { value, label }) => { + return { ...memo, [value]: label }; + }, {}), + }, + }; + }, {}); + }, [operationTypes]); + return ( + + + +
+ +
+
+ + + + {order} + + + + {operationTypesLabels[operation].label} + + + + + + +

{operationTypesLabels[operation].types[type]}

+
+
+ + {probe} + +
+
+ + + + { + onEdit(id); + }} + /> + + + { + onDelete(id); + }} + /> + + + +
+
+
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/edit_discovery_rule.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/edit_discovery_rule.tsx new file mode 100644 index 0000000000000..5059bbabfce91 --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/edit_discovery_rule.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiButton, + EuiButtonEmpty, + EuiFormFieldset, + EuiSelect, + EuiFieldText, + EuiFormRow, + EuiSuperSelect, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + Operation, + DISCOVERY_RULE_TYPE_ALL, + STAGED_DISCOVERY_RULE_ID, +} from '.'; + +interface Props { + id: string; + onChangeOperation: (discoveryItemId: string) => void; + operation: string; + onChangeType: (discoveryItemId: string) => void; + type: string; + onChangeProbe: (discoveryItemId: string) => void; + probe: string; + onCancel: () => void; + onSubmit: () => void; + operationTypes: Operation[]; +} + +export function EditDiscoveryRule({ + id, + onChangeOperation, + operation, + onChangeType, + type, + onChangeProbe, + probe, + onCancel, + onSubmit, + operationTypes, +}: Props) { + return ( + + + + + ({ + text: item.operation.label, + value: item.operation.value, + }))} + value={operation} + onChange={(e) => { + onChangeOperation(e.target.value); + }} + /> + + + + + + + + + definedOperation.value === operation + ) + ?.types.map((item) => ({ + inputDisplay: item.label, + value: item.value, + dropdownDisplay: ( + <> + {item.label} + +

{item.description}

+
+ + ), + })) ?? [] + } + valueOfSelected={type} + onChange={onChangeType} + /> +
+
+
+
+ {type !== DISCOVERY_RULE_TYPE_ALL && ( + + + + + onChangeProbe(e.target.value)} + /> + + + + + )} + + + Cancel + + + + {id === STAGED_DISCOVERY_RULE_ID + ? i18n.translate( + 'xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.add', + { defaultMessage: 'Add' } + ) + : i18n.translate( + 'xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.save', + { defaultMessage: 'Save' } + )} + + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/index.tsx new file mode 100644 index 0000000000000..8f2a1d3d1dea1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/index.tsx @@ -0,0 +1,327 @@ +/* + * Copyright 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 { + htmlIdGenerator, + euiDragDropReorder, + DropResult, + EuiComboBoxOptionOption, +} from '@elastic/eui'; +import React, { useState, useCallback, ReactNode } from 'react'; +import { RuntimeAttachment as RuntimeAttachmentStateless } from './runtime_attachment'; + +export const STAGED_DISCOVERY_RULE_ID = 'STAGED_DISCOVERY_RULE_ID'; +export const DISCOVERY_RULE_TYPE_ALL = 'all'; + +export interface IDiscoveryRule { + operation: string; + type: string; + probe: string; +} + +export type IDiscoveryRuleList = Array<{ + id: string; + discoveryRule: IDiscoveryRule; +}>; + +export interface RuntimeAttachmentSettings { + enabled: boolean; + discoveryRules: IDiscoveryRule[]; + version: string | null; +} + +interface Props { + onChange?: (runtimeAttachmentSettings: RuntimeAttachmentSettings) => void; + toggleDescription: ReactNode; + discoveryRulesDescription: ReactNode; + showUnsavedWarning?: boolean; + initialIsEnabled?: boolean; + initialDiscoveryRules?: IDiscoveryRule[]; + operationTypes: Operation[]; + selectedVersion: string; + versions: string[]; +} + +interface Option { + value: string; + label: string; + description?: string; +} + +export interface Operation { + operation: Option; + types: Option[]; +} + +const versionRegex = new RegExp(/^\d+\.\d+\.\d+$/); +function validateVersion(version: string) { + return versionRegex.test(version); +} + +export function RuntimeAttachment(props: Props) { + const { initialDiscoveryRules = [], onChange = () => {} } = props; + const [isEnabled, setIsEnabled] = useState(Boolean(props.initialIsEnabled)); + const [discoveryRuleList, setDiscoveryRuleList] = + useState( + initialDiscoveryRules.map((discoveryRule) => ({ + id: generateId(), + discoveryRule, + })) + ); + const [editDiscoveryRuleId, setEditDiscoveryRuleId] = useState( + null + ); + const [version, setVersion] = useState(props.selectedVersion); + const [versions, setVersions] = useState(props.versions); + const [isValidVersion, setIsValidVersion] = useState( + validateVersion(version) + ); + + const onToggleEnable = useCallback(() => { + const nextIsEnabled = !isEnabled; + setIsEnabled(nextIsEnabled); + onChange({ + enabled: nextIsEnabled, + discoveryRules: nextIsEnabled + ? discoveryRuleList.map(({ discoveryRule }) => discoveryRule) + : [], + version: nextIsEnabled ? version : null, + }); + }, [isEnabled, onChange, discoveryRuleList, version]); + + const onDelete = useCallback( + (discoveryRuleId: string) => { + const filteredDiscoveryRuleList = discoveryRuleList.filter( + ({ id }) => id !== discoveryRuleId + ); + setDiscoveryRuleList(filteredDiscoveryRuleList); + onChange({ + enabled: isEnabled, + discoveryRules: filteredDiscoveryRuleList.map( + ({ discoveryRule }) => discoveryRule + ), + version, + }); + }, + [isEnabled, discoveryRuleList, onChange, version] + ); + + const onEdit = useCallback( + (discoveryRuleId: string) => { + const editingDiscoveryRule = discoveryRuleList.find( + ({ id }) => id === discoveryRuleId + ); + if (editingDiscoveryRule) { + const { + discoveryRule: { operation, type, probe }, + } = editingDiscoveryRule; + setStagedOperationText(operation); + setStagedTypeText(type); + setStagedProbeText(probe); + setEditDiscoveryRuleId(discoveryRuleId); + } + }, + [discoveryRuleList] + ); + + const [stagedOperationText, setStagedOperationText] = useState(''); + const [stagedTypeText, setStagedTypeText] = useState(''); + const [stagedProbeText, setStagedProbeText] = useState(''); + + const onChangeOperation = useCallback( + (operationText: string) => { + setStagedOperationText(operationText); + const selectedOperationTypes = props.operationTypes.find( + ({ operation }) => operationText === operation.value + ); + const selectedTypeAvailable = selectedOperationTypes?.types.some( + ({ value }) => stagedTypeText === value + ); + if (!selectedTypeAvailable) { + setStagedTypeText(selectedOperationTypes?.types[0].value ?? ''); + } + }, + [props.operationTypes, stagedTypeText] + ); + + const onChangeType = useCallback((operationText: string) => { + setStagedTypeText(operationText); + if (operationText === DISCOVERY_RULE_TYPE_ALL) { + setStagedProbeText(''); + } + }, []); + + const onChangeProbe = useCallback((operationText: string) => { + setStagedProbeText(operationText); + }, []); + + const onCancel = useCallback(() => { + if (editDiscoveryRuleId === STAGED_DISCOVERY_RULE_ID) { + onDelete(STAGED_DISCOVERY_RULE_ID); + } + setEditDiscoveryRuleId(null); + }, [editDiscoveryRuleId, onDelete]); + + const onSubmit = useCallback(() => { + const editDiscoveryRuleIndex = discoveryRuleList.findIndex( + ({ id }) => id === editDiscoveryRuleId + ); + const editDiscoveryRule = discoveryRuleList[editDiscoveryRuleIndex]; + const nextDiscoveryRuleList = [ + ...discoveryRuleList.slice(0, editDiscoveryRuleIndex), + { + id: + editDiscoveryRule.id === STAGED_DISCOVERY_RULE_ID + ? generateId() + : editDiscoveryRule.id, + discoveryRule: { + operation: stagedOperationText, + type: stagedTypeText, + probe: stagedProbeText, + }, + }, + ...discoveryRuleList.slice(editDiscoveryRuleIndex + 1), + ]; + setDiscoveryRuleList(nextDiscoveryRuleList); + setEditDiscoveryRuleId(null); + onChange({ + enabled: isEnabled, + discoveryRules: nextDiscoveryRuleList.map( + ({ discoveryRule }) => discoveryRule + ), + version, + }); + }, [ + isEnabled, + editDiscoveryRuleId, + stagedOperationText, + stagedTypeText, + stagedProbeText, + discoveryRuleList, + onChange, + version, + ]); + + const onAddRule = useCallback(() => { + const firstOperationType = props.operationTypes[0]; + const operationText = firstOperationType.operation.value; + const typeText = firstOperationType.types[0].value; + const valueText = ''; + setStagedOperationText(operationText); + setStagedTypeText(typeText); + setStagedProbeText(valueText); + const nextDiscoveryRuleList = [ + { + id: STAGED_DISCOVERY_RULE_ID, + discoveryRule: { + operation: operationText, + type: typeText, + probe: valueText, + }, + }, + ...discoveryRuleList, + ]; + setDiscoveryRuleList(nextDiscoveryRuleList); + setEditDiscoveryRuleId(STAGED_DISCOVERY_RULE_ID); + }, [discoveryRuleList, props.operationTypes]); + + const onDragEnd = useCallback( + ({ source, destination }: DropResult) => { + if (source && destination) { + const nextDiscoveryRuleList = euiDragDropReorder( + discoveryRuleList, + source.index, + destination.index + ); + setDiscoveryRuleList(nextDiscoveryRuleList); + onChange({ + enabled: isEnabled, + discoveryRules: nextDiscoveryRuleList.map( + ({ discoveryRule }) => discoveryRule + ), + version, + }); + } + }, + [isEnabled, discoveryRuleList, onChange, version] + ); + + function onChangeVersion(nextVersion?: string) { + if (!nextVersion) { + return; + } + setVersion(nextVersion); + onChange({ + enabled: isEnabled, + discoveryRules: isEnabled + ? discoveryRuleList.map(({ discoveryRule }) => discoveryRule) + : [], + version: nextVersion, + }); + } + + function onCreateNewVersion( + newVersion: string, + flattenedOptions: Array> + ) { + const normalizedNewVersion = newVersion.trim().toLowerCase(); + const isNextVersionValid = validateVersion(normalizedNewVersion); + setIsValidVersion(isNextVersionValid); + if (!normalizedNewVersion || !isNextVersionValid) { + return; + } + + // Create the option if it doesn't exist. + if ( + flattenedOptions.findIndex( + (option) => option.label.trim().toLowerCase() === normalizedNewVersion + ) === -1 + ) { + setVersions([...versions, newVersion]); + } + + onChangeVersion(newVersion); + } + + return ( + { + const nextVersion: string | undefined = selectedVersions[0]?.label; + const isNextVersionValid = validateVersion(nextVersion); + setIsValidVersion(isNextVersionValid); + onChangeVersion(nextVersion); + }} + onCreateNewVersion={onCreateNewVersion} + isValidVersion={isValidVersion} + /> + ); +} + +const generateId = htmlIdGenerator(); diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx new file mode 100644 index 0000000000000..12f6705284ff9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx @@ -0,0 +1,484 @@ +/* + * Copyright 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 { Meta, Story } from '@storybook/react'; +import React, { useState } from 'react'; +import { RuntimeAttachment } from '.'; +import { JavaRuntimeAttachment } from './supported_agents/java_runtime_attachment'; + +const stories: Meta<{}> = { + title: 'fleet/Runtime agent attachment', + component: RuntimeAttachment, + decorators: [ + (StoryComponent) => { + return ( +
+ +
+ ); + }, + ], +}; +export default stories; + +const excludeOptions = [ + { value: 'main', label: 'main class / jar name' }, + { value: 'vmarg', label: 'vmarg' }, + { value: 'user', label: 'user' }, +]; +const includeOptions = [{ value: 'all', label: 'All' }, ...excludeOptions]; + +const versions = ['1.27.1', '1.27.0', '1.26.0', '1.25.0']; + +export const RuntimeAttachmentExample: Story = () => { + const [runtimeAttachmentSettings, setRuntimeAttachmentSettings] = useState( + {} + ); + return ( + <> + { + setRuntimeAttachmentSettings(settings); + }} + toggleDescription="Attach the Java agent to running and starting Java applications." + discoveryRulesDescription="For every running JVM, the discovery rules are evaluated in the order they are provided. The first matching rule determines the outcome. Learn more in the docs" + showUnsavedWarning={true} + initialIsEnabled={true} + initialDiscoveryRules={[ + { + operation: 'include', + type: 'main', + probe: 'java-opbeans-10010', + }, + { + operation: 'exclude', + type: 'vmarg', + probe: '10948653898867', + }, + ]} + versions={versions} + selectedVersion={versions[0]} + /> +
+
{JSON.stringify(runtimeAttachmentSettings, null, 4)}
+ + ); +}; + +export const JavaRuntimeAttachmentExample: Story = () => { + return ( + {}} + /> + ); +}; + +const policy = { + id: 'cc380ec5-d84e-40e1-885a-d706edbdc968', + version: 'WzM0MzA2LDJd', + name: 'apm-1', + description: '', + namespace: 'default', + policy_id: 'policy-elastic-agent-on-cloud', + enabled: true, + output_id: '', + inputs: [ + { + type: 'apm', + policy_template: 'apmserver', + enabled: true, + streams: [], + vars: { + host: { + value: 'localhost:8200', + type: 'text', + }, + url: { + value: 'http://localhost:8200', + type: 'text', + }, + secret_token: { + type: 'text', + }, + api_key_enabled: { + value: false, + type: 'bool', + }, + enable_rum: { + value: true, + type: 'bool', + }, + anonymous_enabled: { + value: true, + type: 'bool', + }, + anonymous_allow_agent: { + value: ['rum-js', 'js-base', 'iOS/swift'], + type: 'text', + }, + anonymous_allow_service: { + value: [], + type: 'text', + }, + anonymous_rate_limit_event_limit: { + value: 10, + type: 'integer', + }, + anonymous_rate_limit_ip_limit: { + value: 10000, + type: 'integer', + }, + default_service_environment: { + type: 'text', + }, + rum_allow_origins: { + value: ['"*"'], + type: 'text', + }, + rum_allow_headers: { + value: [], + type: 'text', + }, + rum_response_headers: { + type: 'yaml', + }, + rum_library_pattern: { + value: '"node_modules|bower_components|~"', + type: 'text', + }, + rum_exclude_from_grouping: { + value: '"^/webpack"', + type: 'text', + }, + api_key_limit: { + value: 100, + type: 'integer', + }, + max_event_bytes: { + value: 307200, + type: 'integer', + }, + capture_personal_data: { + value: true, + type: 'bool', + }, + max_header_bytes: { + value: 1048576, + type: 'integer', + }, + idle_timeout: { + value: '45s', + type: 'text', + }, + read_timeout: { + value: '3600s', + type: 'text', + }, + shutdown_timeout: { + value: '30s', + type: 'text', + }, + write_timeout: { + value: '30s', + type: 'text', + }, + max_connections: { + value: 0, + type: 'integer', + }, + response_headers: { + type: 'yaml', + }, + expvar_enabled: { + value: false, + type: 'bool', + }, + tls_enabled: { + value: false, + type: 'bool', + }, + tls_certificate: { + type: 'text', + }, + tls_key: { + type: 'text', + }, + tls_supported_protocols: { + value: ['TLSv1.0', 'TLSv1.1', 'TLSv1.2'], + type: 'text', + }, + tls_cipher_suites: { + value: [], + type: 'text', + }, + tls_curve_types: { + value: [], + type: 'text', + }, + tail_sampling_policies: { + type: 'yaml', + }, + tail_sampling_interval: { + type: 'text', + }, + }, + config: { + 'apm-server': { + value: { + rum: { + source_mapping: { + metadata: [], + }, + }, + agent_config: [], + }, + }, + }, + compiled_input: { + 'apm-server': { + auth: { + anonymous: { + allow_agent: ['rum-js', 'js-base', 'iOS/swift'], + allow_service: null, + enabled: true, + rate_limit: { + event_limit: 10, + ip_limit: 10000, + }, + }, + api_key: { + enabled: false, + limit: 100, + }, + secret_token: null, + }, + capture_personal_data: true, + idle_timeout: '45s', + default_service_environment: null, + 'expvar.enabled': false, + host: 'localhost:8200', + max_connections: 0, + max_event_size: 307200, + max_header_size: 1048576, + read_timeout: '3600s', + response_headers: null, + rum: { + allow_headers: null, + allow_origins: ['*'], + enabled: true, + exclude_from_grouping: '^/webpack', + library_pattern: 'node_modules|bower_components|~', + response_headers: null, + }, + shutdown_timeout: '30s', + write_timeout: '30s', + }, + }, + }, + ], + package: { + name: 'apm', + title: 'Elastic APM', + version: '7.16.0', + }, + elasticsearch: { + privileges: { + cluster: ['cluster:monitor/main'], + }, + }, + revision: 1, + created_at: '2021-11-18T02:14:55.758Z', + created_by: 'admin', + updated_at: '2021-11-18T02:14:55.758Z', + updated_by: 'admin', +}; + +const newPolicy = { + version: 'WzM0MzA2LDJd', + name: 'apm-1', + description: '', + namespace: 'default', + policy_id: 'policy-elastic-agent-on-cloud', + enabled: true, + output_id: '', + package: { + name: 'apm', + title: 'Elastic APM', + version: '8.0.0-dev2', + }, + elasticsearch: { + privileges: { + cluster: ['cluster:monitor/main'], + }, + }, + inputs: [ + { + type: 'apm', + policy_template: 'apmserver', + enabled: true, + vars: { + host: { + value: 'localhost:8200', + type: 'text', + }, + url: { + value: 'http://localhost:8200', + type: 'text', + }, + secret_token: { + type: 'text', + }, + api_key_enabled: { + value: false, + type: 'bool', + }, + enable_rum: { + value: true, + type: 'bool', + }, + anonymous_enabled: { + value: true, + type: 'bool', + }, + anonymous_allow_agent: { + value: ['rum-js', 'js-base', 'iOS/swift'], + type: 'text', + }, + anonymous_allow_service: { + value: [], + type: 'text', + }, + anonymous_rate_limit_event_limit: { + value: 10, + type: 'integer', + }, + anonymous_rate_limit_ip_limit: { + value: 10000, + type: 'integer', + }, + default_service_environment: { + type: 'text', + }, + rum_allow_origins: { + value: ['"*"'], + type: 'text', + }, + rum_allow_headers: { + value: [], + type: 'text', + }, + rum_response_headers: { + type: 'yaml', + }, + rum_library_pattern: { + value: '"node_modules|bower_components|~"', + type: 'text', + }, + rum_exclude_from_grouping: { + value: '"^/webpack"', + type: 'text', + }, + api_key_limit: { + value: 100, + type: 'integer', + }, + max_event_bytes: { + value: 307200, + type: 'integer', + }, + capture_personal_data: { + value: true, + type: 'bool', + }, + max_header_bytes: { + value: 1048576, + type: 'integer', + }, + idle_timeout: { + value: '45s', + type: 'text', + }, + read_timeout: { + value: '3600s', + type: 'text', + }, + shutdown_timeout: { + value: '30s', + type: 'text', + }, + write_timeout: { + value: '30s', + type: 'text', + }, + max_connections: { + value: 0, + type: 'integer', + }, + response_headers: { + type: 'yaml', + }, + expvar_enabled: { + value: false, + type: 'bool', + }, + tls_enabled: { + value: false, + type: 'bool', + }, + tls_certificate: { + type: 'text', + }, + tls_key: { + type: 'text', + }, + tls_supported_protocols: { + value: ['TLSv1.0', 'TLSv1.1', 'TLSv1.2'], + type: 'text', + }, + tls_cipher_suites: { + value: [], + type: 'text', + }, + tls_curve_types: { + value: [], + type: 'text', + }, + tail_sampling_policies: { + type: 'yaml', + }, + tail_sampling_interval: { + type: 'text', + }, + }, + config: { + 'apm-server': { + value: { + rum: { + source_mapping: { + metadata: [], + }, + }, + agent_config: [], + }, + }, + }, + streams: [], + }, + ], +}; diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.tsx new file mode 100644 index 0000000000000..3592eb4f04745 --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.tsx @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiCallOut, + EuiSpacer, + EuiSwitch, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiDragDropContext, + EuiDroppable, + EuiDraggable, + EuiIcon, + DropResult, + EuiComboBox, + EuiComboBoxProps, + EuiFormRow, +} from '@elastic/eui'; +import React, { ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { DiscoveryRule } from './discovery_rule'; +import { DefaultDiscoveryRule } from './default_discovery_rule'; +import { EditDiscoveryRule } from './edit_discovery_rule'; +import { IDiscoveryRuleList, Operation } from '.'; + +interface Props { + isEnabled: boolean; + onToggleEnable: () => void; + discoveryRuleList: IDiscoveryRuleList; + setDiscoveryRuleList: (discoveryRuleItems: IDiscoveryRuleList) => void; + onDelete: (discoveryItemId: string) => void; + editDiscoveryRuleId: null | string; + onEdit: (discoveryItemId: string) => void; + onChangeOperation: (operationText: string) => void; + stagedOperationText: string; + onChangeType: (typeText: string) => void; + stagedTypeText: string; + onChangeProbe: (probeText: string) => void; + stagedProbeText: string; + onCancel: () => void; + onSubmit: () => void; + onAddRule: () => void; + operationTypes: Operation[]; + toggleDescription: ReactNode; + discoveryRulesDescription: ReactNode; + showUnsavedWarning?: boolean; + onDragEnd: (dropResult: DropResult) => void; + selectedVersion: string; + versions: string[]; + onChangeVersion: EuiComboBoxProps['onChange']; + onCreateNewVersion: EuiComboBoxProps['onCreateOption']; + isValidVersion: boolean; +} + +export function RuntimeAttachment({ + isEnabled, + onToggleEnable, + discoveryRuleList, + setDiscoveryRuleList, + onDelete, + editDiscoveryRuleId, + onEdit, + onChangeOperation, + stagedOperationText, + onChangeType, + stagedTypeText, + onChangeProbe, + stagedProbeText, + onCancel, + onSubmit, + onAddRule, + operationTypes, + toggleDescription, + discoveryRulesDescription, + showUnsavedWarning, + onDragEnd, + selectedVersion, + versions, + onChangeVersion, + onCreateNewVersion, + isValidVersion, +}: Props) { + return ( +
+ {showUnsavedWarning && ( + <> + + + + )} + + + + + +

{toggleDescription}

+
+
+ {isEnabled && versions && ( + + + ({ label: _version }))} + onChange={onChangeVersion} + onCreateOption={onCreateNewVersion} + singleSelection + isClearable={false} + /> + + + )} +
+ {isEnabled && ( + <> + + +

+ {i18n.translate( + 'xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.discoveryRules', + { defaultMessage: 'Discovery rules' } + )} +

+
+ + + + + + + +

{discoveryRulesDescription}

+
+
+ + + {i18n.translate( + 'xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.addRule', + { defaultMessage: 'Add rule' } + )} + + +
+ + + + {discoveryRuleList.map(({ discoveryRule, id }, idx) => ( + + {(provided) => + id === editDiscoveryRuleId ? ( + + ) : ( + + ) + } + + ))} + + + + + )} + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/supported_agents/java_runtime_attachment.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/supported_agents/java_runtime_attachment.tsx new file mode 100644 index 0000000000000..2284315d4a6ba --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/supported_agents/java_runtime_attachment.tsx @@ -0,0 +1,276 @@ +/* + * Copyright 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 yaml from 'js-yaml'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback, useState, useMemo } from 'react'; +import { + RuntimeAttachment, + RuntimeAttachmentSettings, + IDiscoveryRule, +} from '..'; +import type { + NewPackagePolicy, + PackagePolicy, + PackagePolicyEditExtensionComponentProps, +} from '../../../apm_policy_form/typings'; + +interface Props { + policy: PackagePolicy; + newPolicy: NewPackagePolicy; + onChange: PackagePolicyEditExtensionComponentProps['onChange']; +} + +const excludeOptions = [ + { + value: 'main', + label: i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.operationType.exclude.options.main', + { defaultMessage: 'main' } + ), + description: i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.operationType.exclude.options.mainDescription', + { + defaultMessage: + 'A regular expression of fully qualified main class names or paths to JARs of applications the java agent should be attached to. Performs a partial match so that foo matches /bin/foo.jar.', + } + ), + }, + { + value: 'vmarg', + label: i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.operationType.exclude.options.vmarg', + { defaultMessage: 'vmarg' } + ), + description: i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.operationType.exclude.options.vmargDescription', + { + defaultMessage: + 'A regular expression matched against the arguments passed to the JVM, such as system properties. Performs a partial match so that attach=true matches the system property -Dattach=true.', + } + ), + }, + { + value: 'user', + label: i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.operationType.exclude.options.user', + { defaultMessage: 'user' } + ), + description: i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.operationType.exclude.options.userDescription', + { + defaultMessage: + 'A username that is matched against the operating system user that runs the JVM.', + } + ), + }, +]; +const includeOptions = [ + { + value: 'all', + label: i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.operationType.include.options.all', + { defaultMessage: 'All' } + ), + description: i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.operationType.include.options.allDescription', + { defaultMessage: 'Includes all JVMs for attachment.' } + ), + }, + ...excludeOptions, +]; + +const versions = [ + '1.27.1', + '1.27.0', + '1.26.0', + '1.25.0', + '1.24.0', + '1.23.0', + '1.22.0', + '1.21.0', + '1.20.0', + '1.19.0', + '1.18.1', + '1.18.0', + '1.18.0.RC1', + '1.17.0', + '1.16.0', + '1.15.0', + '1.14.0', + '1.13.0', + '1.12.0', + '1.11.0', + '1.10.0', + '1.9.0', + '1.8.0', + '1.7.0', + '1.6.1', + '1.6.0', + '1.5.0', + '1.4.0', + '1.3.0', + '1.2.0', +]; + +function getApmVars(newPolicy: NewPackagePolicy) { + return newPolicy.inputs.find(({ type }) => type === 'apm')?.vars; +} + +export function JavaRuntimeAttachment({ newPolicy, onChange }: Props) { + const [isDirty, setIsDirty] = useState(false); + const onChangePolicy = useCallback( + (runtimeAttachmentSettings: RuntimeAttachmentSettings) => { + const apmInputIdx = newPolicy.inputs.findIndex( + ({ type }) => type === 'apm' + ); + onChange({ + isValid: true, + updatedPolicy: { + ...newPolicy, + inputs: [ + ...newPolicy.inputs.slice(0, apmInputIdx), + { + ...newPolicy.inputs[apmInputIdx], + vars: { + ...newPolicy.inputs[apmInputIdx].vars, + java_attacher_enabled: { + value: runtimeAttachmentSettings.enabled, + type: 'bool', + }, + java_attacher_discovery_rules: { + type: 'yaml', + value: encodeDiscoveryRulesYaml( + runtimeAttachmentSettings.discoveryRules + ), + }, + java_attacher_agent_version: { + type: 'text', + value: runtimeAttachmentSettings.version, + }, + }, + }, + ...newPolicy.inputs.slice(apmInputIdx + 1), + ], + }, + }); + setIsDirty(true); + }, + [newPolicy, onChange] + ); + + const apmVars = useMemo(() => getApmVars(newPolicy), [newPolicy]); + + return ( + + {i18n.translate( + 'xpack.apm.fleetIntegration.javaRuntime.discoveryRulesDescription.docLink', + { defaultMessage: 'docs' } + )} + + ), + }} + /> + } + showUnsavedWarning={isDirty} + initialIsEnabled={apmVars?.java_attacher_enabled?.value} + initialDiscoveryRules={decodeDiscoveryRulesYaml( + apmVars?.java_attacher_discovery_rules?.value ?? '[]\n', + [initialDiscoveryRule] + )} + selectedVersion={ + apmVars?.java_attacher_agent_version?.value || versions[0] + } + versions={versions} + /> + ); +} + +const initialDiscoveryRule = { + operation: 'include', + type: 'vmarg', + probe: 'elastic.apm.attach=true', +}; + +type DiscoveryRulesParsedYaml = Array<{ [operationType: string]: string }>; + +function decodeDiscoveryRulesYaml( + discoveryRulesYaml: string, + defaultDiscoveryRules: IDiscoveryRule[] = [] +): IDiscoveryRule[] { + try { + const parsedYaml: DiscoveryRulesParsedYaml = + yaml.load(discoveryRulesYaml) ?? []; + + if (parsedYaml.length === 0) { + return defaultDiscoveryRules; + } + + // transform into array of discovery rules + return parsedYaml.map((discoveryRuleMap) => { + const [operationType, probe] = Object.entries(discoveryRuleMap)[0]; + return { + operation: operationType.split('-')[0], + type: operationType.split('-')[1], + probe, + }; + }); + } catch (error) { + return defaultDiscoveryRules; + } +} + +function encodeDiscoveryRulesYaml(discoveryRules: IDiscoveryRule[]): string { + // transform into list of key,value objects for expected yaml result + const mappedDiscoveryRules: DiscoveryRulesParsedYaml = discoveryRules.map( + ({ operation, type, probe }) => ({ + [`${operation}-${type}`]: probe, + }) + ); + return yaml.dump(mappedDiscoveryRules); +} diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index aa6d69c03d8f6..2cb4e0964686f 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -15,7 +15,7 @@ import { useUiTracker } from '../../../../../observability/public'; import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useApmParams } from '../../../hooks/use_apm_params'; +import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { useTimeRange } from '../../../hooks/use_time_range'; import * as urlHelpers from '../../shared/Links/url_helpers'; @@ -121,8 +121,7 @@ export function TimeComparison() { const { isSmall } = useBreakpoints(); const { query: { rangeFrom, rangeTo }, - // @ts-expect-error Type instantiation is excessively deep and possibly infinite. - } = useApmParams('/services', '/backends/*', '/services/{serviceName}'); + } = useAnyOfApmParams('/services', '/backends/*', '/services/{serviceName}'); const { exactStart, exactEnd } = useTimeRange({ rangeFrom, diff --git a/x-pack/plugins/apm/public/hooks/use_apm_params.ts b/x-pack/plugins/apm/public/hooks/use_apm_params.ts index 12b79ec7c90ae..b4c17c1b329ae 100644 --- a/x-pack/plugins/apm/public/hooks/use_apm_params.ts +++ b/x-pack/plugins/apm/public/hooks/use_apm_params.ts @@ -4,42 +4,29 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { ValuesType } from 'utility-types'; import { TypeOf, PathsOf, useParams } from '@kbn/typed-react-router-config'; import { ApmRoutes } from '../components/routing/apm_route_config'; -export function useApmParams>( +// these three different functions exist purely to speed up completions from +// TypeScript. One overloaded function is expensive because of the size of the +// union type that is created. + +export function useMaybeApmParams>( path: TPath, optional: true -): TypeOf | undefined; +): TypeOf | undefined { + return useParams(path, optional); +} export function useApmParams>( path: TPath -): TypeOf; - -export function useApmParams< - TPath1 extends PathsOf, - TPath2 extends PathsOf ->( - path1: TPath1, - path2: TPath2 -): TypeOf | TypeOf; - -export function useApmParams< - TPath1 extends PathsOf, - TPath2 extends PathsOf, - TPath3 extends PathsOf ->( - path1: TPath1, - path2: TPath2, - path3: TPath3 -): - | TypeOf - | TypeOf - | TypeOf; +): TypeOf { + return useParams(path)!; +} -export function useApmParams( - ...args: any[] -): TypeOf> | undefined { - return useParams(...args); +export function useAnyOfApmParams>>( + ...paths: TPaths +): TypeOf> { + return useParams(...paths)!; } diff --git a/x-pack/plugins/apm/public/hooks/use_apm_router.ts b/x-pack/plugins/apm/public/hooks/use_apm_router.ts index d10b6da857802..dea66d7b2e1c8 100644 --- a/x-pack/plugins/apm/public/hooks/use_apm_router.ts +++ b/x-pack/plugins/apm/public/hooks/use_apm_router.ts @@ -14,6 +14,8 @@ export function useApmRouter() { const { core } = useApmPluginContext(); const link = (...args: [any]) => { + // @ts-expect-error router.link() expects never type, because + // no routes are specified. that's okay. return core.http.basePath.prepend('/app/apm' + router.link(...args)); }; diff --git a/x-pack/plugins/apm/public/hooks/use_current_user.ts b/x-pack/plugins/apm/public/hooks/use_current_user.ts new file mode 100644 index 0000000000000..6f7c999c01e86 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_current_user.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { ApmPluginStartDeps } from '../plugin'; +import { AuthenticatedUser } from '../../../security/common/model'; + +export function useCurrentUser() { + const { + services: { security }, + } = useKibana(); + + const [user, setUser] = useState(); + + useEffect(() => { + const getCurrentUser = async () => { + try { + const authenticatedUser = await security?.authc.getCurrentUser(); + setUser(authenticatedUser); + } catch { + setUser(undefined); + } + }; + getCurrentUser(); + }, [security?.authc]); + + return user; +} diff --git a/x-pack/plugins/apm/server/deprecations/deprecations.test.ts b/x-pack/plugins/apm/server/deprecations/deprecations.test.ts index 11deff82de572..8761e46df9aaa 100644 --- a/x-pack/plugins/apm/server/deprecations/deprecations.test.ts +++ b/x-pack/plugins/apm/server/deprecations/deprecations.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { kibanaPackageJson } from '@kbn/dev-utils'; +import { kibanaPackageJson } from '@kbn/utils'; import { GetDeprecationsContext } from '../../../../../src/core/server'; import { CloudSetup } from '../../../cloud/server'; @@ -27,8 +27,8 @@ describe('getDeprecations', () => { }); }); - describe('when running on cloud with legacy apm-server', () => { - it('returns deprecations', async () => { + describe('when running on cloud without cloud agent policy', () => { + it('returns no deprecations', async () => { const deprecationsCallback = getDeprecations({ branch: 'main', cloudSetup: { isCloudEnabled: true } as unknown as CloudSetup, @@ -39,6 +39,28 @@ describe('getDeprecations', () => { } as unknown as APMRouteHandlerResources['plugins']['fleet'], }); const deprecations = await deprecationsCallback(deprecationContext); + expect(deprecations).toEqual([]); + }); + }); + + describe('when running on cloud with cloud agent policy and without apm integration', () => { + it('returns deprecations', async () => { + const deprecationsCallback = getDeprecations({ + branch: 'main', + cloudSetup: { isCloudEnabled: true } as unknown as CloudSetup, + fleet: { + start: () => ({ + agentPolicyService: { + get: () => + ({ + id: 'foo', + package_policies: [''], + } as AgentPolicy), + }, + }), + } as unknown as APMRouteHandlerResources['plugins']['fleet'], + }); + const deprecations = await deprecationsCallback(deprecationContext); expect(deprecations).not.toEqual([]); // TODO: remove when docs support "main" if (kibanaPackageJson.branch === 'main') { @@ -50,7 +72,7 @@ describe('getDeprecations', () => { }); }); - describe('when running on cloud with fleet', () => { + describe('when running on cloud with cloud agent policy and apm integration', () => { it('returns no deprecations', async () => { const deprecationsCallback = getDeprecations({ branch: 'main', diff --git a/x-pack/plugins/apm/server/deprecations/index.ts b/x-pack/plugins/apm/server/deprecations/index.ts index 6c6567440f267..76637842e9503 100644 --- a/x-pack/plugins/apm/server/deprecations/index.ts +++ b/x-pack/plugins/apm/server/deprecations/index.ts @@ -31,6 +31,8 @@ export function getDeprecations({ if (!fleet) { return deprecations; } + // TODO: remove when docs support "main" + const docBranch = branch === 'main' ? 'master' : branch; const fleetPluginStart = await fleet.start(); const cloudAgentPolicy = await getCloudAgentPolicy({ @@ -39,12 +41,10 @@ export function getDeprecations({ }); const isCloudEnabled = !!cloudSetup?.isCloudEnabled; + const hasCloudAgentPolicy = !isEmpty(cloudAgentPolicy); const hasAPMPackagePolicy = !isEmpty(getApmPackagePolicy(cloudAgentPolicy)); - // TODO: remove when docs support "main" - const docBranch = branch === 'main' ? 'master' : branch; - - if (isCloudEnabled && !hasAPMPackagePolicy) { + if (isCloudEnabled && hasCloudAgentPolicy && !hasAPMPackagePolicy) { deprecations.push({ title: i18n.translate('xpack.apm.deprecations.legacyModeTitle', { defaultMessage: 'APM Server running in legacy mode', diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 416a873bac0a9..958bfb672083a 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -17,6 +17,7 @@ import { APMPlugin } from './plugin'; // All options should be documented in the APM configuration settings: https://github.com/elastic/kibana/blob/main/docs/settings/apm-settings.asciidoc // and be included on cloud allow list unless there are specific reasons not to const configSchema = schema.object({ + autoCreateApmDataView: schema.boolean({ defaultValue: true }), serviceMapEnabled: schema.boolean({ defaultValue: true }), serviceMapFingerprintBucketSize: schema.number({ defaultValue: 100 }), serviceMapTraceIdBucketSize: schema.number({ defaultValue: 65 }), @@ -25,7 +26,6 @@ const configSchema = schema.object({ }), serviceMapTraceIdGlobalBucketSize: schema.number({ defaultValue: 6 }), serviceMapMaxTracesPerRequest: schema.number({ defaultValue: 50 }), - autocreateApmIndexPattern: schema.boolean({ defaultValue: true }), ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), transactionGroupBucketSize: schema.number({ defaultValue: 1000 }), @@ -59,7 +59,15 @@ const configSchema = schema.object({ // plugin config export const config: PluginConfigDescriptor = { - deprecations: ({ renameFromRoot, deprecateFromRoot, unusedFromRoot }) => [ + deprecations: ({ + rename, + renameFromRoot, + deprecateFromRoot, + unusedFromRoot, + }) => [ + rename('autocreateApmIndexPattern', 'autoCreateApmDataView', { + level: 'warning', + }), renameFromRoot( 'apm_oss.transactionIndices', 'xpack.apm.indices.transaction', diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap deleted file mode 100644 index 00440b2b51853..0000000000000 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap +++ /dev/null @@ -1,415 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`transaction group queries fetches metrics top traces 1`] = ` -Array [ - Object { - "apm": Object { - "events": Array [ - "metric", - ], - }, - "body": Object { - "aggs": Object { - "transaction_groups": Object { - "aggs": Object { - "transaction_type": Object { - "top_metrics": Object { - "metrics": Array [ - Object { - "field": "transaction.type", - }, - Object { - "field": "agent.name", - }, - ], - "sort": Object { - "@timestamp": "desc", - }, - }, - }, - }, - "composite": Object { - "size": 10000, - "sources": Array [ - Object { - "service.name": Object { - "terms": Object { - "field": "service.name", - }, - }, - }, - Object { - "transaction.name": Object { - "terms": Object { - "field": "transaction.name", - }, - }, - }, - ], - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "exists": Object { - "field": "transaction.duration.histogram", - }, - }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 0, - "lte": 50000, - }, - }, - }, - Object { - "term": Object { - "transaction.root": true, - }, - }, - ], - "must_not": Array [], - }, - }, - "size": 0, - }, - }, - Object { - "apm": Object { - "events": Array [ - "metric", - ], - }, - "body": Object { - "aggs": Object { - "transaction_groups": Object { - "aggs": Object { - "avg": Object { - "avg": Object { - "field": "transaction.duration.histogram", - }, - }, - }, - "composite": Object { - "size": 10000, - "sources": Array [ - Object { - "service.name": Object { - "terms": Object { - "field": "service.name", - }, - }, - }, - Object { - "transaction.name": Object { - "terms": Object { - "field": "transaction.name", - }, - }, - }, - ], - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "exists": Object { - "field": "transaction.duration.histogram", - }, - }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 0, - "lte": 50000, - }, - }, - }, - Object { - "term": Object { - "transaction.root": true, - }, - }, - ], - "must_not": Array [], - }, - }, - "size": 0, - }, - }, - Object { - "apm": Object { - "events": Array [ - "metric", - ], - }, - "body": Object { - "aggs": Object { - "transaction_groups": Object { - "aggs": Object { - "sum": Object { - "sum": Object { - "field": "transaction.duration.histogram", - }, - }, - }, - "composite": Object { - "size": 10000, - "sources": Array [ - Object { - "service.name": Object { - "terms": Object { - "field": "service.name", - }, - }, - }, - Object { - "transaction.name": Object { - "terms": Object { - "field": "transaction.name", - }, - }, - }, - ], - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "exists": Object { - "field": "transaction.duration.histogram", - }, - }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 0, - "lte": 50000, - }, - }, - }, - Object { - "term": Object { - "transaction.root": true, - }, - }, - ], - "must_not": Array [], - }, - }, - "size": 0, - }, - }, -] -`; - -exports[`transaction group queries fetches top traces 1`] = ` -Array [ - Object { - "apm": Object { - "events": Array [ - "transaction", - ], - }, - "body": Object { - "aggs": Object { - "transaction_groups": Object { - "aggs": Object { - "transaction_type": Object { - "top_metrics": Object { - "metrics": Array [ - Object { - "field": "transaction.type", - }, - Object { - "field": "agent.name", - }, - ], - "sort": Object { - "@timestamp": "desc", - }, - }, - }, - }, - "composite": Object { - "size": 10000, - "sources": Array [ - Object { - "service.name": Object { - "terms": Object { - "field": "service.name", - }, - }, - }, - Object { - "transaction.name": Object { - "terms": Object { - "field": "transaction.name", - }, - }, - }, - ], - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 0, - "lte": 50000, - }, - }, - }, - ], - "must_not": Array [ - Object { - "exists": Object { - "field": "parent.id", - }, - }, - ], - }, - }, - "size": 0, - }, - }, - Object { - "apm": Object { - "events": Array [ - "transaction", - ], - }, - "body": Object { - "aggs": Object { - "transaction_groups": Object { - "aggs": Object { - "avg": Object { - "avg": Object { - "field": "transaction.duration.us", - }, - }, - }, - "composite": Object { - "size": 10000, - "sources": Array [ - Object { - "service.name": Object { - "terms": Object { - "field": "service.name", - }, - }, - }, - Object { - "transaction.name": Object { - "terms": Object { - "field": "transaction.name", - }, - }, - }, - ], - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 0, - "lte": 50000, - }, - }, - }, - ], - "must_not": Array [ - Object { - "exists": Object { - "field": "parent.id", - }, - }, - ], - }, - }, - "size": 0, - }, - }, - Object { - "apm": Object { - "events": Array [ - "transaction", - ], - }, - "body": Object { - "aggs": Object { - "transaction_groups": Object { - "aggs": Object { - "sum": Object { - "sum": Object { - "field": "transaction.duration.us", - }, - }, - }, - "composite": Object { - "size": 10000, - "sources": Array [ - Object { - "service.name": Object { - "terms": Object { - "field": "service.name", - }, - }, - }, - Object { - "transaction.name": Object { - "terms": Object { - "field": "transaction.name", - }, - }, - }, - ], - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 0, - "lte": 50000, - }, - }, - }, - ], - "must_not": Array [ - Object { - "exists": Object { - "field": "parent.id", - }, - }, - ], - }, - }, - "size": 0, - }, - }, -] -`; diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts deleted file mode 100644 index bca71ed71b1f6..0000000000000 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { sortBy } from 'lodash'; -import moment from 'moment'; -import { Unionize } from 'utility-types'; -import { AggregationOptionsByType } from '../../../../../../src/core/types/elasticsearch'; -import { - kqlQuery, - rangeQuery, - termQuery, -} from '../../../../observability/server'; -import { - PARENT_ID, - SERVICE_NAME, - TRANSACTION_NAME, - TRANSACTION_ROOT, -} from '../../../common/elasticsearch_fieldnames'; -import { asMutableArray } from '../../../common/utils/as_mutable_array'; -import { environmentQuery } from '../../../common/utils/environment_query'; -import { joinByKey } from '../../../common/utils/join_by_key'; -import { withApmSpan } from '../../utils/with_apm_span'; -import { - getDocumentTypeFilterForTransactions, - getProcessorEventForTransactions, -} from '../helpers/transactions'; -import { Setup } from '../helpers/setup_request'; -import { getAverages, getCounts, getSums } from './get_transaction_group_stats'; -import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; -export interface TopTraceOptions { - environment: string; - kuery: string; - transactionName?: string; - searchAggregatedTransactions: boolean; - start: number; - end: number; -} - -type Key = Record<'service.name' | 'transaction.name', string>; - -export interface TransactionGroup { - key: Key; - serviceName: string; - transactionName: string; - transactionType: string; - averageResponseTime: number | null | undefined; - transactionsPerMinute: number; - impact: number; - agentName: AgentName; -} - -export type ESResponse = Promise<{ items: TransactionGroup[] }>; - -export type TransactionGroupRequestBase = ReturnType & { - body: { - aggs: { - transaction_groups: Unionize>; - }; - }; -}; - -function getRequest(topTraceOptions: TopTraceOptions) { - const { - searchAggregatedTransactions, - environment, - kuery, - transactionName, - start, - end, - } = topTraceOptions; - - return { - apm: { - events: [getProcessorEventForTransactions(searchAggregatedTransactions)], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - ...termQuery(TRANSACTION_NAME, transactionName), - ...getDocumentTypeFilterForTransactions( - searchAggregatedTransactions - ), - ...rangeQuery(start, end), - ...environmentQuery(environment), - ...kqlQuery(kuery), - ...(searchAggregatedTransactions - ? [ - { - term: { - [TRANSACTION_ROOT]: true, - }, - }, - ] - : []), - ] as QueryDslQueryContainer[], - must_not: [ - ...(!searchAggregatedTransactions - ? [ - { - exists: { - field: PARENT_ID, - }, - }, - ] - : []), - ], - }, - }, - aggs: { - transaction_groups: { - composite: { - sources: asMutableArray([ - { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, - { - [TRANSACTION_NAME]: { - terms: { field: TRANSACTION_NAME }, - }, - }, - ] as const), - // traces overview is hardcoded to 10000 - size: 10000, - }, - }, - }, - }, - }; -} - -export type TransactionGroupSetup = Setup; - -function getItemsWithRelativeImpact( - setup: TransactionGroupSetup, - items: Array<{ - sum?: number | null; - key: Key; - avg?: number | null; - count?: number | null; - transactionType?: string; - agentName?: AgentName; - }>, - start: number, - end: number -) { - const values = items - .map(({ sum }) => sum) - .filter((value) => value !== null) as number[]; - - const max = Math.max(...values); - const min = Math.min(...values); - - const duration = moment.duration(end - start); - const minutes = duration.asMinutes(); - - const itemsWithRelativeImpact = items.map((item) => { - return { - key: item.key, - averageResponseTime: item.avg, - transactionsPerMinute: (item.count ?? 0) / minutes, - transactionType: item.transactionType || '', - impact: - item.sum !== null && item.sum !== undefined - ? ((item.sum - min) / (max - min)) * 100 || 0 - : 0, - agentName: item.agentName as AgentName, - }; - }); - - return itemsWithRelativeImpact; -} - -export function topTransactionGroupsFetcher( - topTraceOptions: TopTraceOptions, - setup: TransactionGroupSetup -): Promise<{ items: TransactionGroup[] }> { - return withApmSpan('get_top_traces', async () => { - const request = getRequest(topTraceOptions); - - const params = { - request, - setup, - searchAggregatedTransactions: - topTraceOptions.searchAggregatedTransactions, - }; - - const [counts, averages, sums] = await Promise.all([ - getCounts(params), - getAverages(params), - getSums(params), - ]); - - const stats = [...averages, ...counts, ...sums]; - - const items = joinByKey(stats, 'key'); - - const { start, end } = topTraceOptions; - - const itemsWithRelativeImpact = getItemsWithRelativeImpact( - setup, - items, - start, - end - ); - - const itemsWithKeys = itemsWithRelativeImpact.map((item) => ({ - ...item, - transactionName: item.key[TRANSACTION_NAME], - serviceName: item.key[SERVICE_NAME], - })); - - return { - // sort by impact by default so most impactful services are not cut off - items: sortBy(itemsWithKeys, 'impact').reverse(), - }; - }); -} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts index b4f2c4b4bee11..4bd49f0db15e1 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts @@ -12,7 +12,6 @@ import { TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { EventOutcome } from '../../../common/event_outcome'; -import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; import { kqlQuery, rangeQuery, @@ -121,72 +120,3 @@ export async function getFailedTransactionRate({ return { timeseries, average }; } - -export async function getFailedTransactionRatePeriods({ - environment, - kuery, - serviceName, - transactionType, - transactionName, - setup, - searchAggregatedTransactions, - comparisonStart, - comparisonEnd, - start, - end, -}: { - environment: string; - kuery: string; - serviceName: string; - transactionType?: string; - transactionName?: string; - setup: Setup; - searchAggregatedTransactions: boolean; - comparisonStart?: number; - comparisonEnd?: number; - start: number; - end: number; -}) { - const commonProps = { - environment, - kuery, - serviceName, - transactionType, - transactionName, - setup, - searchAggregatedTransactions, - }; - - const currentPeriodPromise = getFailedTransactionRate({ - ...commonProps, - start, - end, - }); - - const previousPeriodPromise = - comparisonStart && comparisonEnd - ? getFailedTransactionRate({ - ...commonProps, - start: comparisonStart, - end: comparisonEnd, - }) - : { timeseries: [], average: null }; - - const [currentPeriod, previousPeriod] = await Promise.all([ - currentPeriodPromise, - previousPeriodPromise, - ]); - - const currentPeriodTimeseries = currentPeriod.timeseries; - - return { - currentPeriod, - previousPeriod: { - ...previousPeriod, - timeseries: offsetPreviousPeriodCoordinates({ - currentPeriodTimeseries, - previousPeriodTimeseries: previousPeriod.timeseries, - }), - }, - }; -} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts deleted file mode 100644 index 97dc298d11c56..0000000000000 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { merge } from 'lodash'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { - AGENT_NAME, - TRANSACTION_TYPE, - TRANSACTION_NAME, - SERVICE_NAME, -} from '../../../common/elasticsearch_fieldnames'; -import { arrayUnionToCallable } from '../../../common/utils/array_union_to_callable'; -import { TransactionGroupRequestBase, TransactionGroupSetup } from './fetcher'; -import { getDurationFieldForTransactions } from '../helpers/transactions'; -import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; -interface MetricParams { - request: TransactionGroupRequestBase; - setup: TransactionGroupSetup; - searchAggregatedTransactions: boolean; -} - -type BucketKey = Record; - -function mergeRequestWithAggs< - TRequestBase extends TransactionGroupRequestBase, - TAggregationMap extends Record< - string, - estypes.AggregationsAggregationContainer - > ->(request: TRequestBase, aggs: TAggregationMap) { - return merge({}, request, { - body: { - aggs: { - transaction_groups: { - aggs, - }, - }, - }, - }); -} - -export async function getAverages({ - request, - setup, - searchAggregatedTransactions, -}: MetricParams) { - const params = mergeRequestWithAggs(request, { - avg: { - avg: { - field: getDurationFieldForTransactions(searchAggregatedTransactions), - }, - }, - }); - - const response = await setup.apmEventClient.search( - 'get_avg_transaction_group_duration', - params - ); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - avg: bucket.avg.value, - }; - }); -} - -export async function getCounts({ request, setup }: MetricParams) { - const params = mergeRequestWithAggs(request, { - transaction_type: { - top_metrics: { - sort: { - '@timestamp': 'desc' as const, - }, - metrics: [ - { - field: TRANSACTION_TYPE, - } as const, - { - field: AGENT_NAME, - } as const, - ], - }, - }, - }); - - const response = await setup.apmEventClient.search( - 'get_transaction_group_transaction_count', - params - ); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - count: bucket.doc_count, - transactionType: bucket.transaction_type.top[0].metrics[ - TRANSACTION_TYPE - ] as string, - agentName: bucket.transaction_type.top[0].metrics[ - AGENT_NAME - ] as AgentName, - }; - }); -} - -export async function getSums({ - request, - setup, - searchAggregatedTransactions, -}: MetricParams) { - const params = mergeRequestWithAggs(request, { - sum: { - sum: { - field: getDurationFieldForTransactions(searchAggregatedTransactions), - }, - }, - }); - - const response = await setup.apmEventClient.search( - 'get_transaction_group_latency_sums', - params - ); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - sum: bucket.sum.value, - }; - }); -} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/transaction_groups/index.ts deleted file mode 100644 index bb16125ae8d09..0000000000000 --- a/x-pack/plugins/apm/server/lib/transaction_groups/index.ts +++ /dev/null @@ -1,16 +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 { Setup } from '../helpers/setup_request'; -import { topTransactionGroupsFetcher, TopTraceOptions } from './fetcher'; - -export async function getTopTransactionGroupList( - options: TopTraceOptions, - setup: Setup -) { - return await topTransactionGroupsFetcher(options, setup); -} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/mock_responses/transaction_groups_response.ts b/x-pack/plugins/apm/server/lib/transaction_groups/mock_responses/transaction_groups_response.ts deleted file mode 100644 index 1ec8d7cd76ca3..0000000000000 --- a/x-pack/plugins/apm/server/lib/transaction_groups/mock_responses/transaction_groups_response.ts +++ /dev/null @@ -1,2723 +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 { ESResponse } from '../fetcher'; - -export const transactionGroupsResponse = { - took: 139, - timed_out: false, - _shards: { total: 44, successful: 44, skipped: 0, failed: 0 }, - hits: { total: 131557, max_score: null, hits: [] }, - aggregations: { - transaction_groups: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: { transaction: 'POST /api/orders' }, - doc_count: 180, - avg: { value: 255966.30555555556 }, - p95: { values: { '95.0': 320238.5 } }, - sum: { value: 46073935 }, - sample: { - hits: { - total: 180, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'TBGQKGcBVMxP8Wrugd8L', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:43:32.010Z', - context: { - request: { - http_version: '1.1', - method: 'POST', - url: { - port: '3000', - pathname: '/api/orders', - full: 'http://opbeans-node:3000/api/orders', - raw: '/api/orders', - protocol: 'http:', - hostname: 'opbeans-node', - }, - socket: { - encrypted: false, - remote_address: '::ffff:172.18.0.10', - }, - headers: { - host: 'opbeans-node:3000', - accept: 'application/json', - 'content-type': 'application/json', - 'content-length': '129', - connection: 'close', - 'user-agent': 'workload/2.4.3', - }, - body: '[REDACTED]', - }, - response: { - status_code: 200, - headers: { - date: 'Sun, 18 Nov 2018 20:43:32 GMT', - connection: 'close', - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - 'content-length': '13', - etag: 'W/"d-g9K2iK4ordyN88lGL4LmPlYNfhc"', - }, - }, - system: { - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - }, - process: { - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 2413, - ppid: 1, - title: 'node /app/server.js', - }, - service: { - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - language: { name: 'javascript' }, - }, - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - }, - custom: { containerId: 4669 }, - }, - trace: { id: '2b1252a338249daeecf6afb0c236e31b' }, - timestamp: { us: 1542573812010006 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - sampled: true, - span_count: { started: 16 }, - id: '2c9f39e9ec4a0111', - name: 'POST /api/orders', - duration: { us: 291572 }, - type: 'request', - result: 'HTTP 2xx', - }, - }, - sort: [1542573812010], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api' }, - doc_count: 21911, - avg: { value: 48021.972616494 }, - p95: { values: { '95.0': 67138.18364917398 } }, - sum: { value: 1052209442 }, - sample: { - hits: { - total: 21911, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '_hKZKGcBVMxP8Wru1G13', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:44.070Z', - timestamp: { us: 1542574424070007 }, - agent: { - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - type: 'apm-server', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - sampled: true, - span_count: { started: 1 }, - id: 'a78bca581dcd8ff8', - name: 'GET /api', - duration: { us: 8684 }, - type: 'request', - result: 'HTTP 4xx', - }, - context: { - response: { - status_code: 404, - headers: { - 'content-type': 'application/json;charset=UTF-8', - 'transfer-encoding': 'chunked', - date: 'Sun, 18 Nov 2018 20:53:43 GMT', - connection: 'close', - 'x-powered-by': 'Express', - }, - }, - system: { - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - }, - process: { - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3756, - ppid: 1, - title: 'node /app/server.js', - }, - service: { - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - language: { name: 'javascript' }, - }, - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - custom: { containerId: 5176 }, - request: { - method: 'GET', - url: { - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/api/types/3', - full: 'http://opbeans-node:3000/api/types/3', - raw: '/api/types/3', - }, - socket: { - encrypted: false, - remote_address: '::ffff:172.18.0.6', - }, - headers: { - 'accept-encoding': 'gzip, deflate', - accept: '*/*', - connection: 'keep-alive', - 'elastic-apm-traceparent': - '00-86c68779d8a65b06fb78e770ffc436a5-4aaea53dc1791183-01', - host: 'opbeans-node:3000', - 'user-agent': 'python-requests/2.20.0', - }, - http_version: '1.1', - }, - }, - parent: { id: '4aaea53dc1791183' }, - trace: { id: '86c68779d8a65b06fb78e770ffc436a5' }, - }, - sort: [1542574424070], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/orders' }, - doc_count: 3247, - avg: { value: 33265.03326147213 }, - p95: { values: { '95.0': 58827.489999999976 } }, - sum: { value: 108011563 }, - sample: { - hits: { - total: 3247, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '6BKZKGcBVMxP8Wru1G13', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:40.973Z', - timestamp: { us: 1542574420973006 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - type: 'request', - result: 'HTTP 2xx', - sampled: true, - span_count: { started: 2 }, - id: '89f200353eb50539', - name: 'GET /api/orders', - duration: { us: 23040 }, - }, - context: { - user: { - username: 'kimchy', - email: 'kimchy@elastic.co', - id: '42', - }, - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - custom: { containerId: 408 }, - request: { - method: 'GET', - url: { - full: 'http://opbeans-node:3000/api/orders', - raw: '/api/orders', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/api/orders', - }, - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - connection: 'close', - }, - http_version: '1.1', - }, - response: { - status_code: 200, - headers: { - etag: 'W/"194bc-cOw6+iRf7XCeqMXHrle3IOig7tY"', - date: 'Sun, 18 Nov 2018 20:53:40 GMT', - connection: 'close', - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - 'content-length': '103612', - }, - }, - system: { - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - }, - process: { - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3756, - ppid: 1, - }, - service: { - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - }, - }, - trace: { id: '0afce85f593cbbdd09949936fe964f0f' }, - }, - sort: [1542574420973], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /log-message' }, - doc_count: 700, - avg: { value: 32900.72714285714 }, - p95: { values: { '95.0': 40444 } }, - sum: { value: 23030509 }, - sample: { - hits: { - total: 700, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'qBKVKGcBVMxP8Wruqi_j', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:49:09.225Z', - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - sampled: true, - span_count: { started: 0 }, - id: 'b9a8f96d7554d09f', - name: 'GET /log-message', - duration: { us: 32381 }, - type: 'request', - result: 'HTTP 5xx', - }, - context: { - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - }, - custom: { containerId: 321 }, - request: { - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - connection: 'close', - }, - http_version: '1.1', - method: 'GET', - url: { - raw: '/log-message', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/log-message', - full: 'http://opbeans-node:3000/log-message', - }, - }, - response: { - status_code: 500, - headers: { - 'x-powered-by': 'Express', - 'content-type': 'text/html; charset=utf-8', - 'content-length': '24', - etag: 'W/"18-MS3VbhH7auHMzO0fUuNF6v14N/M"', - date: 'Sun, 18 Nov 2018 20:49:09 GMT', - connection: 'close', - }, - }, - system: { - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - }, - process: { - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3142, - ppid: 1, - }, - service: { - language: { name: 'javascript' }, - runtime: { version: '8.12.0', name: 'node' }, - name: 'opbeans-node', - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - }, - }, - trace: { id: 'ba18b741cdd3ac83eca89a5fede47577' }, - timestamp: { us: 1542574149225004 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - }, - sort: [1542574149225], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/stats' }, - doc_count: 4639, - avg: { value: 32554.36257814184 }, - p95: { values: { '95.0': 59356.73611111111 } }, - sum: { value: 151019688 }, - sample: { - hits: { - total: 4639, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '9hKZKGcBVMxP8Wru1G13', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:42.560Z', - trace: { id: '63ccc3b0929dafb7f2fbcabdc7f7af25' }, - timestamp: { us: 1542574422560002 }, - agent: { - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - type: 'apm-server', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - sampled: true, - span_count: { started: 7 }, - id: 'fb754e7628da2fb5', - name: 'GET /api/stats', - duration: { us: 28753 }, - type: 'request', - result: 'HTTP 3xx', - }, - context: { - response: { - headers: { - 'x-powered-by': 'Express', - etag: 'W/"77-uxKJrX5GSMJJWTKh3orUFAEVxSs"', - date: 'Sun, 18 Nov 2018 20:53:42 GMT', - connection: 'keep-alive', - }, - status_code: 304, - }, - system: { - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - }, - process: { - pid: 3756, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - service: { - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - }, - user: { - email: 'kimchy@elastic.co', - id: '42', - username: 'kimchy', - }, - tags: { - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - }, - custom: { containerId: 207 }, - request: { - url: { - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/api/stats', - full: 'http://opbeans-node:3000/api/stats', - raw: '/api/stats', - }, - socket: { - remote_address: '::ffff:172.18.0.7', - encrypted: false, - }, - headers: { - 'if-none-match': 'W/"77-uxKJrX5GSMJJWTKh3orUFAEVxSs"', - host: 'opbeans-node:3000', - connection: 'keep-alive', - 'user-agent': 'Chromeless 1.4.0', - 'elastic-apm-traceparent': - '00-63ccc3b0929dafb7f2fbcabdc7f7af25-821a787e73ab1563-01', - accept: '*/*', - referer: 'http://opbeans-node:3000/dashboard', - 'accept-encoding': 'gzip, deflate', - }, - http_version: '1.1', - method: 'GET', - }, - }, - parent: { id: '821a787e73ab1563' }, - }, - sort: [1542574422560], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /log-error' }, - doc_count: 736, - avg: { value: 32387.73641304348 }, - p95: { values: { '95.0': 40061.1 } }, - sum: { value: 23837374 }, - sample: { - hits: { - total: 736, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'rBKYKGcBVMxP8Wru9mC0', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:52:51.462Z', - host: { name: 'b359e3afece8' }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - sampled: true, - span_count: { started: 0 }, - id: 'ec9c465c5042ded8', - name: 'GET /log-error', - duration: { us: 33367 }, - type: 'request', - result: 'HTTP 5xx', - }, - context: { - service: { - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - }, - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - }, - custom: { containerId: 4877 }, - request: { - http_version: '1.1', - method: 'GET', - url: { - full: 'http://opbeans-node:3000/log-error', - raw: '/log-error', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/log-error', - }, - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - connection: 'close', - }, - }, - response: { - headers: { - date: 'Sun, 18 Nov 2018 20:52:51 GMT', - connection: 'close', - 'x-powered-by': 'Express', - 'content-type': 'text/html; charset=utf-8', - 'content-length': '24', - etag: 'W/"18-MS3VbhH7auHMzO0fUuNF6v14N/M"', - }, - status_code: 500, - }, - system: { - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - }, - process: { - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3659, - ppid: 1, - title: 'node /app/server.js', - }, - }, - trace: { id: '15366d65659b5fc8f67ff127391b3aff' }, - timestamp: { us: 1542574371462005 }, - }, - sort: [1542574371462], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/customers' }, - doc_count: 3366, - avg: { value: 32159.926322043968 }, - p95: { values: { '95.0': 59845.85714285714 } }, - sum: { value: 108250312 }, - sample: { - hits: { - total: 3366, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'aRKZKGcBVMxP8Wruf2ly', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:21.180Z', - transaction: { - sampled: true, - span_count: { started: 2 }, - id: '94852b9dd1075982', - name: 'GET /api/customers', - duration: { us: 18077 }, - type: 'request', - result: 'HTTP 2xx', - }, - context: { - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - custom: { containerId: 2531 }, - request: { - http_version: '1.1', - method: 'GET', - url: { - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/api/customers', - full: 'http://opbeans-node:3000/api/customers', - raw: '/api/customers', - }, - socket: { - remote_address: '::ffff:172.18.0.6', - encrypted: false, - }, - headers: { - accept: '*/*', - connection: 'keep-alive', - 'elastic-apm-traceparent': - '00-541025da8ecc2f51f21c1a4ad6992b77-ca18d9d4c3879519-01', - host: 'opbeans-node:3000', - 'user-agent': 'python-requests/2.20.0', - 'accept-encoding': 'gzip, deflate', - }, - }, - response: { - status_code: 200, - headers: { - etag: 'W/"2d991-yG3J8W/roH7fSxXTudZrO27Ax9s"', - date: 'Sun, 18 Nov 2018 20:53:21 GMT', - connection: 'keep-alive', - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - 'content-length': '186769', - }, - }, - system: { - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - }, - process: { - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3710, - ppid: 1, - }, - service: { - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - }, - }, - parent: { id: 'ca18d9d4c3879519' }, - trace: { id: '541025da8ecc2f51f21c1a4ad6992b77' }, - timestamp: { us: 1542574401180002 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - }, - sort: [1542574401180], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/products/top' }, - doc_count: 3694, - avg: { value: 27516.89144558744 }, - p95: { values: { '95.0': 56064.679999999986 } }, - sum: { value: 101647397 }, - sample: { - hits: { - total: 3694, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'LhKZKGcBVMxP8WruHWMl', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:52:57.316Z', - host: { name: 'b359e3afece8' }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - span_count: { started: 4 }, - id: 'be4bd5475d5d9e6f', - name: 'GET /api/products/top', - duration: { us: 48781 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - }, - context: { - request: { - headers: { - host: 'opbeans-node:3000', - connection: 'keep-alive', - 'user-agent': 'Chromeless 1.4.0', - 'elastic-apm-traceparent': - '00-74f12e705936d66350f4741ebeb55189-fcebe94cd2136215-01', - accept: '*/*', - referer: 'http://opbeans-node:3000/dashboard', - 'accept-encoding': 'gzip, deflate', - }, - http_version: '1.1', - method: 'GET', - url: { - port: '3000', - pathname: '/api/products/top', - full: 'http://opbeans-node:3000/api/products/top', - raw: '/api/products/top', - protocol: 'http:', - hostname: 'opbeans-node', - }, - socket: { - remote_address: '::ffff:172.18.0.7', - encrypted: false, - }, - }, - response: { - status_code: 200, - headers: { - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - 'content-length': '282', - etag: 'W/"11a-lcI9zuMZYYsDRpEZgYqDYr96cKM"', - date: 'Sun, 18 Nov 2018 20:52:57 GMT', - connection: 'keep-alive', - }, - }, - system: { - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - }, - process: { - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3686, - ppid: 1, - }, - service: { - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { name: 'nodejs', version: '1.14.2' }, - }, - user: { - username: 'kimchy', - email: 'kimchy@elastic.co', - id: '42', - }, - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - custom: { containerId: 5113 }, - }, - parent: { id: 'fcebe94cd2136215' }, - trace: { id: '74f12e705936d66350f4741ebeb55189' }, - timestamp: { us: 1542574377316005 }, - }, - sort: [1542574377316], - }, - ], - }, - }, - }, - { - key: { transaction: 'POST /api' }, - doc_count: 147, - avg: { value: 21331.714285714286 }, - p95: { values: { '95.0': 30938 } }, - sum: { value: 3135762 }, - sample: { - hits: { - total: 147, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'DhGDKGcBVMxP8WruzRXV', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:29:42.751Z', - transaction: { - duration: { us: 21083 }, - type: 'request', - result: 'HTTP 4xx', - sampled: true, - span_count: { started: 1 }, - id: 'd67c2f7aa897110c', - name: 'POST /api', - }, - context: { - user: { - email: 'kimchy@elastic.co', - id: '42', - username: 'kimchy', - }, - tags: { - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - }, - custom: { containerId: 2927 }, - request: { - url: { - raw: '/api/orders', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/api/orders', - full: 'http://opbeans-node:3000/api/orders', - }, - socket: { - encrypted: false, - remote_address: '::ffff:172.18.0.10', - }, - headers: { - accept: 'application/json', - 'content-type': 'application/json', - 'content-length': '129', - connection: 'close', - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - }, - body: '[REDACTED]', - http_version: '1.1', - method: 'POST', - }, - response: { - status_code: 400, - headers: { - 'x-powered-by': 'Express', - date: 'Sun, 18 Nov 2018 20:29:42 GMT', - 'content-length': '0', - connection: 'close', - }, - }, - system: { - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - }, - process: { - pid: 546, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - service: { - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - }, - }, - trace: { id: '8ed4d94ec8fc11b1ea1b0aa59c2320ff' }, - timestamp: { us: 1542572982751005 }, - agent: { - version: '7.0.0-alpha1', - type: 'apm-server', - hostname: 'b359e3afece8', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - }, - sort: [1542572982751], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/products/:id/customers' }, - doc_count: 2102, - avg: { value: 17189.329210275926 }, - p95: { values: { '95.0': 39284.79999999999 } }, - sum: { value: 36131970 }, - sample: { - hits: { - total: 2102, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'lhKVKGcBVMxP8WruDCUH', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:48:24.769Z', - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - event: 'transaction', - name: 'transaction', - }, - transaction: { - type: 'request', - result: 'HTTP 2xx', - sampled: true, - span_count: { started: 1 }, - id: '2a87ae20ad04ee0c', - name: 'GET /api/products/:id/customers', - duration: { us: 49338 }, - }, - context: { - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - }, - custom: { containerId: 1735 }, - request: { - headers: { - accept: '*/*', - connection: 'keep-alive', - 'elastic-apm-traceparent': - '00-28f178c354d17f400dea04bc4a7b3c57-68f5d1607cac7779-01', - host: 'opbeans-node:3000', - 'user-agent': 'python-requests/2.20.0', - 'accept-encoding': 'gzip, deflate', - }, - http_version: '1.1', - method: 'GET', - url: { - port: '3000', - pathname: '/api/products/2/customers', - full: 'http://opbeans-node:3000/api/products/2/customers', - raw: '/api/products/2/customers', - protocol: 'http:', - hostname: 'opbeans-node', - }, - socket: { - remote_address: '::ffff:172.18.0.6', - encrypted: false, - }, - }, - response: { - status_code: 200, - headers: { - 'content-length': '186570', - etag: 'W/"2d8ca-Z9NzuHyGyxwtzpOkcIxBvzm24iw"', - date: 'Sun, 18 Nov 2018 20:48:24 GMT', - connection: 'keep-alive', - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - }, - }, - system: { - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - }, - process: { - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3100, - ppid: 1, - title: 'node /app/server.js', - }, - service: { - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - }, - }, - parent: { id: '68f5d1607cac7779' }, - trace: { id: '28f178c354d17f400dea04bc4a7b3c57' }, - timestamp: { us: 1542574104769029 }, - }, - sort: [1542574104769], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/types/:id' }, - doc_count: 1449, - avg: { value: 12763.68806073154 }, - p95: { values: { '95.0': 30576.749999999996 } }, - sum: { value: 18494584 }, - sample: { - hits: { - total: 1449, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'lxKZKGcBVMxP8WrurGuW', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:35.967Z', - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - id: '053436abacdec0a4', - name: 'GET /api/types/:id', - duration: { us: 13064 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - span_count: { started: 2 }, - }, - context: { - process: { - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3756, - ppid: 1, - }, - service: { - name: 'opbeans-node', - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - }, - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - custom: { containerId: 5345 }, - request: { - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - connection: 'close', - }, - http_version: '1.1', - method: 'GET', - url: { - pathname: '/api/types/1', - full: 'http://opbeans-node:3000/api/types/1', - raw: '/api/types/1', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - }, - }, - response: { - status_code: 200, - headers: { - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - 'content-length': '217', - etag: 'W/"d9-cebOOHODBQMZd1wt+ZZBaSPgQLQ"', - date: 'Sun, 18 Nov 2018 20:53:35 GMT', - connection: 'close', - }, - }, - system: { - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - }, - }, - trace: { id: '2223b30b5cbaf2e221fcf70ac6d9abbe' }, - timestamp: { us: 1542574415967005 }, - host: { name: 'b359e3afece8' }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - }, - sort: [1542574415967], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/products' }, - doc_count: 3678, - avg: { value: 12683.190864600327 }, - p95: { values: { '95.0': 35009.67999999999 } }, - sum: { value: 46648776 }, - sample: { - hits: { - total: 3678, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '-hKZKGcBVMxP8Wru1G13', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:43.477Z', - trace: { id: 'bee00a8efb523ca4b72adad57f7caba3' }, - timestamp: { us: 1542574423477006 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - span_count: { started: 2 }, - id: 'd8fc6d3b8707b64c', - name: 'GET /api/products', - duration: { us: 6915 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - }, - context: { - custom: { containerId: 2857 }, - request: { - headers: { - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - connection: 'close', - }, - http_version: '1.1', - method: 'GET', - url: { - full: 'http://opbeans-node:3000/api/products', - raw: '/api/products', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/api/products', - }, - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - }, - response: { - status_code: 200, - headers: { - connection: 'close', - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - 'content-length': '1023', - etag: 'W/"3ff-VyOxcDApb+a/lnjkm9FeTOGSDrs"', - date: 'Sun, 18 Nov 2018 20:53:43 GMT', - }, - }, - system: { - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - }, - process: { - pid: 3756, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - service: { - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - language: { name: 'javascript' }, - }, - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - }, - }, - sort: [1542574423477], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/types' }, - doc_count: 2400, - avg: { value: 11257.757916666667 }, - p95: { values: { '95.0': 35222.944444444445 } }, - sum: { value: 27018619 }, - sample: { - hits: { - total: 2400, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '_xKZKGcBVMxP8Wru1G13', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:44.978Z', - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - id: '0f10668e4fb3adc7', - name: 'GET /api/types', - duration: { us: 7891 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - span_count: { started: 2 }, - }, - context: { - request: { - http_version: '1.1', - method: 'GET', - url: { - hostname: 'opbeans-node', - port: '3000', - pathname: '/api/types', - full: 'http://opbeans-node:3000/api/types', - raw: '/api/types', - protocol: 'http:', - }, - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - connection: 'close', - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - }, - }, - response: { - status_code: 200, - headers: { - 'content-length': '112', - etag: 'W/"70-1z6hT7P1WHgBgS/BeUEVeHhOCQU"', - date: 'Sun, 18 Nov 2018 20:53:44 GMT', - connection: 'close', - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - }, - }, - system: { - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - }, - process: { - pid: 3756, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - service: { - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - }, - user: { - email: 'kimchy@elastic.co', - id: '42', - username: 'kimchy', - }, - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - custom: { containerId: 2193 }, - }, - trace: { id: '0d84126973411c19b470f2d9eea958d3' }, - timestamp: { us: 1542574424978005 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - }, - sort: [1542574424978], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/orders/:id' }, - doc_count: 1283, - avg: { value: 10584.05144193297 }, - p95: { values: { '95.0': 26555.399999999998 } }, - sum: { value: 13579338 }, - sample: { - hits: { - total: 1283, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'SRKXKGcBVMxP8Wru41Gf', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:51:36.949Z', - context: { - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - custom: { containerId: 5999 }, - request: { - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - connection: 'close', - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - }, - http_version: '1.1', - method: 'GET', - url: { - raw: '/api/orders/183', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/api/orders/183', - full: 'http://opbeans-node:3000/api/orders/183', - }, - }, - response: { - headers: { - date: 'Sun, 18 Nov 2018 20:51:36 GMT', - connection: 'close', - 'content-length': '0', - 'x-powered-by': 'Express', - }, - status_code: 404, - }, - system: { - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - }, - process: { - pid: 3475, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - service: { - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - }, - user: { - username: 'kimchy', - email: 'kimchy@elastic.co', - id: '42', - }, - }, - trace: { id: 'dab6421fa44a6869887e0edf32e1ad6f' }, - timestamp: { us: 1542574296949004 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - span_count: { started: 1 }, - id: '937ef5588454f74a', - name: 'GET /api/orders/:id', - duration: { us: 5906 }, - type: 'request', - result: 'HTTP 4xx', - sampled: true, - }, - }, - sort: [1542574296949], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/products/:id' }, - doc_count: 1839, - avg: { value: 10548.218597063622 }, - p95: { values: { '95.0': 28413.383333333328 } }, - sum: { value: 19398174 }, - sample: { - hits: { - total: 1839, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'OxKZKGcBVMxP8WruHWMl', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:52:57.963Z', - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - span_count: { started: 1 }, - id: 'd324897ffb7ebcdc', - name: 'GET /api/products/:id', - duration: { us: 6959 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - }, - context: { - service: { - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { version: '8.12.0', name: 'node' }, - }, - user: { - email: 'kimchy@elastic.co', - id: '42', - username: 'kimchy', - }, - tags: { - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - }, - custom: { containerId: 7184 }, - request: { - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - host: 'opbeans-node:3000', - connection: 'close', - 'user-agent': 'workload/2.4.3', - }, - http_version: '1.1', - method: 'GET', - url: { - port: '3000', - pathname: '/api/products/3', - full: 'http://opbeans-node:3000/api/products/3', - raw: '/api/products/3', - protocol: 'http:', - hostname: 'opbeans-node', - }, - }, - response: { - status_code: 200, - headers: { - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - 'content-length': '231', - etag: 'W/"e7-kkuzj37GZDzXDh0CWqh5Gan0VO4"', - date: 'Sun, 18 Nov 2018 20:52:57 GMT', - connection: 'close', - }, - }, - system: { - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - }, - process: { - pid: 3686, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - }, - trace: { id: 'ca86ec845e412e4b4506a715d51548ec' }, - timestamp: { us: 1542574377963005 }, - }, - sort: [1542574377963], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /api/customers/:id' }, - doc_count: 1900, - avg: { value: 9868.217894736843 }, - p95: { values: { '95.0': 27486.5 } }, - sum: { value: 18749614 }, - sample: { - hits: { - total: 1900, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'IhKZKGcBVMxP8WruHGPb', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:52:56.797Z', - agent: { - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - type: 'apm-server', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - span_count: { started: 1 }, - id: '60e230d12f3f0960', - name: 'GET /api/customers/:id', - duration: { us: 9735 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - }, - context: { - response: { - status_code: 200, - headers: { - connection: 'keep-alive', - 'x-powered-by': 'Express', - 'content-type': 'application/json; charset=utf-8', - 'content-length': '193', - etag: 'W/"c1-LbuhkuLzFyZ0H+7+JQGA5b0kvNs"', - date: 'Sun, 18 Nov 2018 20:52:56 GMT', - }, - }, - system: { - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - }, - process: { - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3686, - }, - service: { - name: 'opbeans-node', - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - }, - user: { - username: 'kimchy', - email: 'kimchy@elastic.co', - id: '42', - }, - tags: { - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - }, - custom: { containerId: 8225 }, - request: { - headers: { - 'accept-encoding': 'gzip, deflate', - accept: '*/*', - connection: 'keep-alive', - 'elastic-apm-traceparent': - '00-e6140d30363f18b585f5d3b753f4d025-aa82e2c847265626-01', - host: 'opbeans-node:3000', - 'user-agent': 'python-requests/2.20.0', - }, - http_version: '1.1', - method: 'GET', - url: { - pathname: '/api/customers/700', - full: 'http://opbeans-node:3000/api/customers/700', - raw: '/api/customers/700', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - }, - socket: { - remote_address: '::ffff:172.18.0.6', - encrypted: false, - }, - }, - }, - parent: { id: 'aa82e2c847265626' }, - trace: { id: 'e6140d30363f18b585f5d3b753f4d025' }, - timestamp: { us: 1542574376797031 }, - }, - sort: [1542574376797], - }, - ], - }, - }, - }, - { - key: { transaction: 'POST unknown route' }, - doc_count: 20, - avg: { value: 5192.9 }, - p95: { values: { '95.0': 13230.5 } }, - sum: { value: 103858 }, - sample: { - hits: { - total: 20, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '4wsiKGcBVMxP8Wru2j59', - _score: null, - _source: { - '@timestamp': '2018-11-18T18:43:50.994Z', - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - sampled: true, - span_count: { started: 0 }, - id: '92c3ceea57899061', - name: 'POST unknown route', - duration: { us: 3467 }, - type: 'request', - result: 'HTTP 4xx', - }, - context: { - system: { - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - }, - process: { - pid: 19196, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - service: { - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - }, - user: { - email: 'kimchy@elastic.co', - id: '42', - username: 'kimchy', - }, - tags: { - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - }, - custom: { containerId: 6102 }, - request: { - method: 'POST', - url: { - raw: '/api/orders/csv', - protocol: 'http:', - hostname: '172.18.0.9', - port: '3000', - pathname: '/api/orders/csv', - full: 'http://172.18.0.9:3000/api/orders/csv', - }, - socket: { - remote_address: '::ffff:172.18.0.9', - encrypted: false, - }, - headers: { - 'accept-encoding': 'gzip, deflate', - 'content-type': - 'multipart/form-data; boundary=2b2e40be188a4cb5a56c05a0c182f6c9', - 'elastic-apm-traceparent': - '00-19688959ea6cbccda8013c11566ea329-1fc3665eef2dcdfc-01', - 'x-forwarded-for': '172.18.0.11', - host: '172.18.0.9:3000', - 'user-agent': 'Python/3.7 aiohttp/3.3.2', - 'content-length': '380', - accept: '*/*', - }, - body: '[REDACTED]', - http_version: '1.1', - }, - response: { - headers: { - date: 'Sun, 18 Nov 2018 18:43:50 GMT', - connection: 'keep-alive', - 'x-powered-by': 'Express', - 'content-security-policy': "default-src 'self'", - 'x-content-type-options': 'nosniff', - 'content-type': 'text/html; charset=utf-8', - 'content-length': '154', - }, - status_code: 404, - }, - }, - parent: { id: '1fc3665eef2dcdfc' }, - trace: { id: '19688959ea6cbccda8013c11566ea329' }, - timestamp: { us: 1542566630994005 }, - agent: { - version: '7.0.0-alpha1', - type: 'apm-server', - hostname: 'b359e3afece8', - }, - }, - sort: [1542566630994], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /is-it-coffee-time' }, - doc_count: 358, - avg: { value: 4694.005586592179 }, - p95: { values: { '95.0': 11022.99999999992 } }, - sum: { value: 1680454 }, - sample: { - hits: { - total: 358, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '7RKSKGcBVMxP8Wru-gjC', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:46:19.317Z', - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - id: '319a5c555a1ab207', - name: 'GET /is-it-coffee-time', - duration: { us: 4253 }, - type: 'request', - result: 'HTTP 5xx', - sampled: true, - span_count: { started: 0 }, - }, - context: { - process: { - pid: 2760, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - service: { - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - }, - user: { - email: 'kimchy@elastic.co', - id: '42', - username: 'kimchy', - }, - tags: { - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - }, - custom: { containerId: 8593 }, - request: { - headers: { - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - connection: 'close', - }, - http_version: '1.1', - method: 'GET', - url: { - port: '3000', - pathname: '/is-it-coffee-time', - full: 'http://opbeans-node:3000/is-it-coffee-time', - raw: '/is-it-coffee-time', - protocol: 'http:', - hostname: 'opbeans-node', - }, - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - }, - response: { - status_code: 500, - headers: { - date: 'Sun, 18 Nov 2018 20:46:19 GMT', - connection: 'close', - 'x-powered-by': 'Express', - 'content-security-policy': "default-src 'self'", - 'x-content-type-options': 'nosniff', - 'content-type': 'text/html; charset=utf-8', - 'content-length': '148', - }, - }, - system: { - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - }, - }, - trace: { id: '821812b416de4c73ced87f8777fa46a6' }, - timestamp: { us: 1542573979317007 }, - }, - sort: [1542573979317], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET /throw-error' }, - doc_count: 336, - avg: { value: 4549.889880952381 }, - p95: { values: { '95.0': 7719.700000000001 } }, - sum: { value: 1528763 }, - sample: { - hits: { - total: 336, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: 'PhKTKGcBVMxP8WruwxSG', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:47:10.714Z', - agent: { - version: '7.0.0-alpha1', - type: 'apm-server', - hostname: 'b359e3afece8', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - id: 'ecd187dc53f09fbd', - name: 'GET /throw-error', - duration: { us: 4458 }, - type: 'request', - result: 'HTTP 5xx', - sampled: true, - span_count: { started: 0 }, - }, - context: { - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - }, - custom: { containerId: 7220 }, - request: { - http_version: '1.1', - method: 'GET', - url: { - port: '3000', - pathname: '/throw-error', - full: 'http://opbeans-node:3000/throw-error', - raw: '/throw-error', - protocol: 'http:', - hostname: 'opbeans-node', - }, - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - connection: 'close', - }, - }, - response: { - status_code: 500, - headers: { - 'x-content-type-options': 'nosniff', - 'content-type': 'text/html; charset=utf-8', - 'content-length': '148', - date: 'Sun, 18 Nov 2018 20:47:10 GMT', - connection: 'close', - 'x-powered-by': 'Express', - 'content-security-policy': "default-src 'self'", - }, - }, - system: { - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - }, - process: { - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 2895, - ppid: 1, - }, - service: { - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - }, - }, - trace: { id: '6c0ef23e1f963f304ce440a909914d35' }, - timestamp: { us: 1542574030714012 }, - }, - sort: [1542574030714], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET *' }, - doc_count: 7115, - avg: { value: 3504.5108924806746 }, - p95: { values: { '95.0': 11431.738095238095 } }, - sum: { value: 24934595 }, - sample: { - hits: { - total: 7115, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '6hKZKGcBVMxP8Wru1G13', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:42.493Z', - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - span_count: { started: 0 }, - id: 'f5fc4621949b63fb', - name: 'GET *', - duration: { us: 1901 }, - type: 'request', - result: 'HTTP 3xx', - sampled: true, - }, - context: { - request: { - http_version: '1.1', - method: 'GET', - url: { - hostname: 'opbeans-node', - port: '3000', - pathname: '/dashboard', - full: 'http://opbeans-node:3000/dashboard', - raw: '/dashboard', - protocol: 'http:', - }, - socket: { - remote_address: '::ffff:172.18.0.7', - encrypted: false, - }, - headers: { - accept: - 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate', - 'if-none-match': 'W/"280-1670775e878"', - 'if-modified-since': 'Mon, 12 Nov 2018 10:27:07 GMT', - host: 'opbeans-node:3000', - connection: 'keep-alive', - 'upgrade-insecure-requests': '1', - 'user-agent': 'Chromeless 1.4.0', - }, - }, - response: { - status_code: 304, - headers: { - 'x-powered-by': 'Express', - 'accept-ranges': 'bytes', - 'cache-control': 'public, max-age=0', - 'last-modified': 'Mon, 12 Nov 2018 10:27:07 GMT', - etag: 'W/"280-1670775e878"', - date: 'Sun, 18 Nov 2018 20:53:42 GMT', - connection: 'keep-alive', - }, - }, - system: { - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - }, - process: { - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3756, - ppid: 1, - }, - service: { - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - agent: { version: '1.14.2', name: 'nodejs' }, - }, - user: { - email: 'kimchy@elastic.co', - id: '42', - username: 'kimchy', - }, - tags: { - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - }, - custom: { containerId: 6446 }, - }, - trace: { id: '7efb6ade88cdea20cd96ca482681cde7' }, - timestamp: { us: 1542574422493006 }, - }, - sort: [1542574422493], - }, - ], - }, - }, - }, - { - key: { transaction: 'OPTIONS unknown route' }, - doc_count: 364, - avg: { value: 2742.4615384615386 }, - p95: { values: { '95.0': 4370.000000000002 } }, - sum: { value: 998256 }, - sample: { - hits: { - total: 364, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '-xKVKGcBVMxP8WrucSs2', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:49:00.707Z', - timestamp: { us: 1542574140707006 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - span_count: { started: 0 }, - id: 'a8c87ebc7ec68bc0', - name: 'OPTIONS unknown route', - duration: { us: 2371 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - }, - context: { - user: { - id: '42', - username: 'kimchy', - email: 'kimchy@elastic.co', - }, - tags: { - 'this-is-a-very-long-tag-name-without-any-spaces': - 'test', - 'multi-line': 'foo\nbar\nbaz', - foo: 'bar', - lorem: - 'ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus, ipsum id scelerisque consequat, enim leo vulputate massa, vel ultricies ante neque ac risus. Curabitur tincidunt vitae sapien id pulvinar. Mauris eu vestibulum tortor. Integer sit amet lorem fringilla, egestas tellus vitae, vulputate purus. Nulla feugiat blandit nunc et semper. Morbi purus libero, mattis sed mauris non, euismod iaculis lacus. Curabitur eleifend ante eros, non faucibus velit lacinia id. Duis posuere libero augue, at dignissim urna consectetur eget. Praesent eu congue est, iaculis finibus augue.', - }, - custom: { containerId: 3775 }, - request: { - socket: { - remote_address: '::ffff:172.18.0.10', - encrypted: false, - }, - headers: { - 'user-agent': 'workload/2.4.3', - host: 'opbeans-node:3000', - 'content-length': '0', - connection: 'close', - }, - http_version: '1.1', - method: 'OPTIONS', - url: { - port: '3000', - pathname: '/', - full: 'http://opbeans-node:3000/', - raw: '/', - protocol: 'http:', - hostname: 'opbeans-node', - }, - }, - response: { - status_code: 200, - headers: { - 'content-type': 'text/html; charset=utf-8', - 'content-length': '8', - etag: 'W/"8-ZRAf8oNBS3Bjb/SU2GYZCmbtmXg"', - date: 'Sun, 18 Nov 2018 20:49:00 GMT', - connection: 'close', - 'x-powered-by': 'Express', - allow: 'GET,HEAD', - }, - }, - system: { - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - }, - process: { - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3142, - }, - service: { - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - }, - }, - trace: { id: '469e3e5f91ffe3195a8e58cdd1cdefa8' }, - }, - sort: [1542574140707], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET static file' }, - doc_count: 62606, - avg: { value: 2651.8784461553205 }, - p95: { values: { '95.0': 6140.579335038363 } }, - sum: { value: 166023502 }, - sample: { - hits: { - total: 62606, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '-RKZKGcBVMxP8Wru1G13', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:43.304Z', - context: { - system: { - platform: 'linux', - ip: '172.18.0.10', - hostname: '98195610c255', - architecture: 'x64', - }, - process: { - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - pid: 3756, - ppid: 1, - }, - service: { - name: 'opbeans-node', - agent: { name: 'nodejs', version: '1.14.2' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - }, - request: { - headers: { - 'user-agent': 'curl/7.38.0', - host: 'opbeans-node:3000', - accept: '*/*', - }, - http_version: '1.1', - method: 'GET', - url: { - pathname: '/', - full: 'http://opbeans-node:3000/', - raw: '/', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - }, - socket: { - encrypted: false, - remote_address: '::ffff:172.18.0.10', - }, - }, - response: { - status_code: 200, - headers: { - 'content-length': '640', - 'accept-ranges': 'bytes', - 'cache-control': 'public, max-age=0', - etag: 'W/"280-1670775e878"', - 'x-powered-by': 'Express', - 'last-modified': 'Mon, 12 Nov 2018 10:27:07 GMT', - 'content-type': 'text/html; charset=UTF-8', - date: 'Sun, 18 Nov 2018 20:53:43 GMT', - connection: 'keep-alive', - }, - }, - }, - trace: { id: 'b303d2a4a007946b63b9db7fafe639a0' }, - timestamp: { us: 1542574423304006 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - span_count: { started: 0 }, - id: '2869c13633534be5', - name: 'GET static file', - duration: { us: 1801 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - }, - }, - sort: [1542574423304], - }, - ], - }, - }, - }, - { - key: { transaction: 'GET unknown route' }, - doc_count: 7487, - avg: { value: 1422.926672899693 }, - p95: { values: { '95.0': 2311.885238095238 } }, - sum: { value: 10653452 }, - sample: { - hits: { - total: 7487, - max_score: null, - hits: [ - { - _index: 'apm-7.0.0-alpha1-2018.11.18', - _type: 'doc', - _id: '6xKZKGcBVMxP8Wru1G13', - _score: null, - _source: { - '@timestamp': '2018-11-18T20:53:42.504Z', - processor: { - name: 'transaction', - event: 'transaction', - }, - transaction: { - name: 'GET unknown route', - duration: { us: 911 }, - type: 'request', - result: 'HTTP 2xx', - sampled: true, - span_count: { started: 0 }, - id: '107881ae2be1b56d', - }, - context: { - system: { - hostname: '98195610c255', - architecture: 'x64', - platform: 'linux', - ip: '172.18.0.10', - }, - process: { - pid: 3756, - ppid: 1, - title: 'node /app/server.js', - argv: [ - '/usr/local/bin/node', - '/usr/local/lib/node_modules/pm2/lib/ProcessContainerFork.js', - ], - }, - service: { - agent: { version: '1.14.2', name: 'nodejs' }, - version: '1.0.0', - language: { name: 'javascript' }, - runtime: { name: 'node', version: '8.12.0' }, - name: 'opbeans-node', - }, - request: { - http_version: '1.1', - method: 'GET', - url: { - full: 'http://opbeans-node:3000/rum-config.js', - raw: '/rum-config.js', - protocol: 'http:', - hostname: 'opbeans-node', - port: '3000', - pathname: '/rum-config.js', - }, - socket: { - remote_address: '::ffff:172.18.0.7', - encrypted: false, - }, - headers: { - connection: 'keep-alive', - 'user-agent': 'Chromeless 1.4.0', - accept: '*/*', - referer: 'http://opbeans-node:3000/dashboard', - 'accept-encoding': 'gzip, deflate', - host: 'opbeans-node:3000', - }, - }, - response: { - headers: { - 'x-powered-by': 'Express', - 'content-type': 'text/javascript', - 'content-length': '172', - date: 'Sun, 18 Nov 2018 20:53:42 GMT', - connection: 'keep-alive', - }, - status_code: 200, - }, - }, - trace: { id: '4399e7233e6e7b77e70c2fff111b8f28' }, - timestamp: { us: 1542574422504004 }, - agent: { - type: 'apm-server', - hostname: 'b359e3afece8', - version: '7.0.0-alpha1', - }, - host: { name: 'b359e3afece8' }, - }, - sort: [1542574422504], - }, - ], - }, - }, - }, - ], - }, - }, -} as unknown as ESResponse; diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts b/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts deleted file mode 100644 index fd42ffe42788f..0000000000000 --- a/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts +++ /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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { topTransactionGroupsFetcher } from './fetcher'; -import { - SearchParamsMock, - inspectSearchParams, -} from '../../utils/test_helpers'; -import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; - -describe('transaction group queries', () => { - let mock: SearchParamsMock; - - afterEach(() => { - mock.teardown(); - }); - - it('fetches top traces', async () => { - mock = await inspectSearchParams((setup) => - topTransactionGroupsFetcher( - { - searchAggregatedTransactions: false, - environment: ENVIRONMENT_ALL.value, - kuery: '', - start: 0, - end: 50000, - }, - setup - ) - ); - - const allParams = mock.spy.mock.calls.map((call) => call[1]); - - expect(allParams).toMatchSnapshot(); - }); - it('fetches metrics top traces', async () => { - mock = await inspectSearchParams((setup) => - topTransactionGroupsFetcher( - { - searchAggregatedTransactions: true, - environment: ENVIRONMENT_ALL.value, - kuery: '', - start: 0, - end: 50000, - }, - setup - ) - ); - - const allParams = mock.spy.mock.calls.map((call) => call[1]); - - expect(allParams).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index b603d9e72a2b0..2f8e10d68ae51 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -47,7 +47,6 @@ import { TRANSACTION_TYPE, } from '../common/elasticsearch_fieldnames'; import { tutorialProvider } from './tutorial'; -import { getDeprecations } from './deprecations'; export class APMPlugin implements @@ -197,14 +196,6 @@ export class APMPlugin kibanaVersion: this.initContext.env.packageInfo.version, }); - core.deprecations.registerDeprecations({ - getDeprecations: getDeprecations({ - cloudSetup: plugins.cloud, - fleet: resourcePlugins.fleet, - branch: this.initContext.env.packageInfo.branch, - }), - }); - return { config$, getApmIndices: boundGetApmIndices, diff --git a/x-pack/plugins/apm/server/routes/agent_keys/create_agent_key.ts b/x-pack/plugins/apm/server/routes/agent_keys/create_agent_key.ts index 02207dad32efb..d83a7af2737cd 100644 --- a/x-pack/plugins/apm/server/routes/agent_keys/create_agent_key.ts +++ b/x-pack/plugins/apm/server/routes/agent_keys/create_agent_key.ts @@ -8,17 +8,14 @@ import Boom from '@hapi/boom'; import { ApmPluginRequestHandlerContext } from '../typings'; import { CreateApiKeyResponse } from '../../../common/agent_key_types'; +import { PrivilegeType } from '../../../common/privilege_type'; -const enum PrivilegeType { - SOURCEMAP = 'sourcemap:write', - EVENT = 'event:write', - AGENT_CONFIG = 'config_agent:read', -} +const resource = '*'; interface SecurityHasPrivilegesResponse { application: { apm: { - '-': { + [resource]: { [PrivilegeType.SOURCEMAP]: boolean; [PrivilegeType.EVENT]: boolean; [PrivilegeType.AGENT_CONFIG]: boolean; @@ -36,75 +33,56 @@ export async function createAgentKey({ context: ApmPluginRequestHandlerContext; requestBody: { name: string; - sourcemap?: boolean; - event?: boolean; - agentConfig?: boolean; + privileges: string[]; }; }) { + const { name, privileges } = requestBody; + const application = { + application: 'apm', + privileges, + resources: [resource], + }; + // Elasticsearch will allow a user without the right apm privileges to create API keys, but the keys won't validate // check first whether the user has the right privileges, and bail out early if not const { - body: { application, username, has_all_requested: hasRequiredPrivileges }, + body: { + application: userApplicationPrivileges, + username, + has_all_requested: hasRequiredPrivileges, + }, } = await context.core.elasticsearch.client.asCurrentUser.security.hasPrivileges( { body: { - application: [ - { - application: 'apm', - privileges: [ - PrivilegeType.SOURCEMAP, - PrivilegeType.EVENT, - PrivilegeType.AGENT_CONFIG, - ], - resources: ['-'], - }, - ], + application: [application], }, } ); if (!hasRequiredPrivileges) { - const missingPrivileges = Object.entries(application.apm['-']) + const missingPrivileges = Object.entries( + userApplicationPrivileges.apm[resource] + ) .filter((x) => !x[1]) - .map((x) => x[0]) - .join(', '); - const error = `${username} is missing the following requested privilege(s): ${missingPrivileges}.\ - You might try with the superuser, or add the APM application privileges to the role of the authenticated user, eg.: - PUT /_security/role/my_role { + .map((x) => x[0]); + + const error = `${username} is missing the following requested privilege(s): ${missingPrivileges.join( + ', ' + )}.\ + You might try with the superuser, or add the missing APM application privileges to the role of the authenticated user, eg.: + PUT /_security/role/my_role + { ... "applications": [{ "application": "apm", - "privileges": ["sourcemap:write", "event:write", "config_agent:read"], - "resources": ["*"] + "privileges": ${JSON.stringify(missingPrivileges)}, + "resources": [${resource}] }], ... }`; throw Boom.internal(error); } - const { name = 'apm-key', sourcemap, event, agentConfig } = requestBody; - - const privileges: PrivilegeType[] = []; - if (!sourcemap && !event && !agentConfig) { - privileges.push( - PrivilegeType.SOURCEMAP, - PrivilegeType.EVENT, - PrivilegeType.AGENT_CONFIG - ); - } - - if (sourcemap) { - privileges.push(PrivilegeType.SOURCEMAP); - } - - if (event) { - privileges.push(PrivilegeType.EVENT); - } - - if (agentConfig) { - privileges.push(PrivilegeType.AGENT_CONFIG); - } - const body = { name, metadata: { @@ -114,13 +92,7 @@ export async function createAgentKey({ apm: { cluster: [], index: [], - applications: [ - { - application: 'apm', - privileges, - resources: ['*'], - }, - ], + applications: [application], }, }, }; diff --git a/x-pack/plugins/apm/server/routes/agent_keys/invalidate_agent_key.ts b/x-pack/plugins/apm/server/routes/agent_keys/invalidate_agent_key.ts index e2f86298efdca..1ccb63382de4e 100644 --- a/x-pack/plugins/apm/server/routes/agent_keys/invalidate_agent_key.ts +++ b/x-pack/plugins/apm/server/routes/agent_keys/invalidate_agent_key.ts @@ -19,6 +19,7 @@ export async function invalidateAgentKey({ { body: { ids: [id], + owner: true, }, } ); diff --git a/x-pack/plugins/apm/server/routes/agent_keys/route.ts b/x-pack/plugins/apm/server/routes/agent_keys/route.ts index 44bbb22e703b5..5878ce75680ac 100644 --- a/x-pack/plugins/apm/server/routes/agent_keys/route.ts +++ b/x-pack/plugins/apm/server/routes/agent_keys/route.ts @@ -8,13 +8,13 @@ import Boom from '@hapi/boom'; import { i18n } from '@kbn/i18n'; import * as t from 'io-ts'; -import { toBooleanRt } from '@kbn/io-ts-utils/to_boolean_rt'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { createApmServerRouteRepository } from '../apm_routes/create_apm_server_route_repository'; import { getAgentKeys } from './get_agent_keys'; import { getAgentKeysPrivileges } from './get_agent_keys_privileges'; import { invalidateAgentKey } from './invalidate_agent_key'; import { createAgentKey } from './create_agent_key'; +import { privilegesTypeRt } from '../../../common/privilege_type'; const agentKeysRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/agent_keys', @@ -77,19 +77,13 @@ const invalidateAgentKeyRoute = createApmServerRoute({ }); const createAgentKeyRoute = createApmServerRoute({ - endpoint: 'POST /apm/agent_keys', + endpoint: 'POST /api/apm/agent_keys', options: { tags: ['access:apm', 'access:apm_write'] }, params: t.type({ - body: t.intersection([ - t.partial({ - sourcemap: toBooleanRt, - event: toBooleanRt, - agentConfig: toBooleanRt, - }), - t.type({ - name: t.string, - }), - ]), + body: t.type({ + name: t.string, + privileges: privilegesTypeRt, + }), }), handler: async (resources) => { const { context, params } = resources; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_boolean_field_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_boolean_field_stats.ts index c936e626a5599..a41e3370c1063 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_boolean_field_stats.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_boolean_field_stats.ts @@ -7,8 +7,6 @@ import { ElasticsearchClient } from 'kibana/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - -import { buildSamplerAggregation } from '../../utils/field_stats_utils'; import { FieldValuePair } from '../../../../../common/correlations/types'; import { FieldStatsCommonRequestParams, @@ -25,7 +23,7 @@ export const getBooleanFieldStatsRequest = ( ): estypes.SearchRequest => { const query = getQueryWithParams({ params, termFilters }); - const { index, samplerShardSize } = params; + const { index } = params; const size = 0; const aggs: Aggs = { @@ -42,14 +40,13 @@ export const getBooleanFieldStatsRequest = ( const searchBody = { query, - aggs: { - sample: buildSamplerAggregation(aggs, samplerShardSize), - }, + aggs, }; return { index, size, + track_total_hits: false, body: searchBody, }; }; @@ -67,19 +64,17 @@ export const fetchBooleanFieldStats = async ( ); const { body } = await esClient.search(request); const aggregations = body.aggregations as { - sample: { - sampled_value_count: estypes.AggregationsFiltersBucketItemKeys; - sampled_values: estypes.AggregationsTermsAggregate; - }; + sampled_value_count: estypes.AggregationsFiltersBucketItemKeys; + sampled_values: estypes.AggregationsTermsAggregate; }; const stats: BooleanFieldStats = { fieldName: field.fieldName, - count: aggregations?.sample.sampled_value_count.doc_count ?? 0, + count: aggregations?.sampled_value_count.doc_count ?? 0, }; const valueBuckets: TopValueBucket[] = - aggregations?.sample.sampled_values?.buckets ?? []; + aggregations?.sampled_values?.buckets ?? []; valueBuckets.forEach((bucket) => { stats[`${bucket.key.toString()}Count`] = bucket.doc_count; }); diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_field_stats.test.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_field_stats.test.ts index 2775d755c9907..30bebc4c24774 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_field_stats.test.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_field_stats.test.ts @@ -20,7 +20,6 @@ const params = { includeFrozen: false, environment: ENVIRONMENT_ALL.value, kuery: '', - samplerShardSize: 5000, }; export const getExpectedQuery = (aggs: any) => { @@ -46,6 +45,7 @@ export const getExpectedQuery = (aggs: any) => { }, index: 'apm-*', size: 0, + track_total_hits: false, }; }; @@ -55,28 +55,16 @@ describe('field_stats', () => { const req = getNumericFieldStatsRequest(params, 'url.path'); const expectedAggs = { - sample: { - aggs: { - sampled_field_stats: { - aggs: { actual_stats: { stats: { field: 'url.path' } } }, - filter: { exists: { field: 'url.path' } }, - }, - sampled_percentiles: { - percentiles: { - field: 'url.path', - keyed: false, - percents: [50], - }, - }, - sampled_top: { - terms: { - field: 'url.path', - order: { _count: 'desc' }, - size: 10, - }, - }, + sampled_field_stats: { + aggs: { actual_stats: { stats: { field: 'url.path' } } }, + filter: { exists: { field: 'url.path' } }, + }, + sampled_top: { + terms: { + field: 'url.path', + order: { _count: 'desc' }, + size: 10, }, - sampler: { shard_size: 5000 }, }, }; expect(req).toEqual(getExpectedQuery(expectedAggs)); @@ -87,13 +75,8 @@ describe('field_stats', () => { const req = getKeywordFieldStatsRequest(params, 'url.path'); const expectedAggs = { - sample: { - sampler: { shard_size: 5000 }, - aggs: { - sampled_top: { - terms: { field: 'url.path', size: 10, order: { _count: 'desc' } }, - }, - }, + sampled_top: { + terms: { field: 'url.path', size: 10 }, }, }; expect(req).toEqual(getExpectedQuery(expectedAggs)); @@ -104,15 +87,10 @@ describe('field_stats', () => { const req = getBooleanFieldStatsRequest(params, 'url.path'); const expectedAggs = { - sample: { - sampler: { shard_size: 5000 }, - aggs: { - sampled_value_count: { - filter: { exists: { field: 'url.path' } }, - }, - sampled_values: { terms: { field: 'url.path', size: 2 } }, - }, + sampled_value_count: { + filter: { exists: { field: 'url.path' } }, }, + sampled_values: { terms: { field: 'url.path', size: 2 } }, }; expect(req).toEqual(getExpectedQuery(expectedAggs)); }); diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_field_value_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_field_value_stats.ts new file mode 100644 index 0000000000000..0fa508eff508c --- /dev/null +++ b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_field_value_stats.ts @@ -0,0 +1,76 @@ +/* + * Copyright 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 { ElasticsearchClient } from 'kibana/server'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { FieldValuePair } from '../../../../../common/correlations/types'; +import { + FieldStatsCommonRequestParams, + FieldValueFieldStats, + Aggs, + TopValueBucket, +} from '../../../../../common/correlations/field_stats_types'; +import { getQueryWithParams } from '../get_query_with_params'; + +export const getFieldValueFieldStatsRequest = ( + params: FieldStatsCommonRequestParams, + field?: FieldValuePair +): estypes.SearchRequest => { + const query = getQueryWithParams({ params }); + + const { index } = params; + + const size = 0; + const aggs: Aggs = { + filtered_count: { + filter: { + term: { + [`${field?.fieldName}`]: field?.fieldValue, + }, + }, + }, + }; + + const searchBody = { + query, + aggs, + }; + + return { + index, + size, + track_total_hits: false, + body: searchBody, + }; +}; + +export const fetchFieldValueFieldStats = async ( + esClient: ElasticsearchClient, + params: FieldStatsCommonRequestParams, + field: FieldValuePair +): Promise => { + const request = getFieldValueFieldStatsRequest(params, field); + + const { body } = await esClient.search(request); + const aggregations = body.aggregations as { + filtered_count: estypes.AggregationsFiltersBucketItemKeys; + }; + const topValues: TopValueBucket[] = [ + { + key: field.fieldValue, + doc_count: aggregations.filtered_count.doc_count, + }, + ]; + + const stats = { + fieldName: field.fieldName, + topValues, + topValuesSampleSize: aggregations.filtered_count.doc_count ?? 0, + }; + + return stats; +}; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_fields_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_fields_stats.ts index 8b41f7662679c..513252ee65e11 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_fields_stats.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_fields_stats.ts @@ -8,10 +8,7 @@ import { ElasticsearchClient } from 'kibana/server'; import { chunk } from 'lodash'; import { ES_FIELD_TYPES } from '@kbn/field-types'; -import { - FieldValuePair, - CorrelationsParams, -} from '../../../../../common/correlations/types'; +import { FieldValuePair } from '../../../../../common/correlations/types'; import { FieldStats, FieldStatsCommonRequestParams, @@ -23,7 +20,7 @@ import { fetchBooleanFieldStats } from './get_boolean_field_stats'; export const fetchFieldsStats = async ( esClient: ElasticsearchClient, - params: CorrelationsParams, + fieldStatsParams: FieldStatsCommonRequestParams, fieldsToSample: string[], termFilters?: FieldValuePair[] ): Promise<{ stats: FieldStats[]; errors: any[] }> => { @@ -33,14 +30,10 @@ export const fetchFieldsStats = async ( if (fieldsToSample.length === 0) return { stats, errors }; const respMapping = await esClient.fieldCaps({ - ...getRequestBase(params), + ...getRequestBase(fieldStatsParams), fields: fieldsToSample, }); - const fieldStatsParams: FieldStatsCommonRequestParams = { - ...params, - samplerShardSize: 5000, - }; const fieldStatsPromises = Object.entries(respMapping.body.fields) .map(([key, value], idx) => { const field: FieldValuePair = { fieldName: key, fieldValue: '' }; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_keyword_field_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_keyword_field_stats.ts index c64bbc6678779..16ba4f24f5e93 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_keyword_field_stats.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_keyword_field_stats.ts @@ -14,7 +14,6 @@ import { Aggs, TopValueBucket, } from '../../../../../common/correlations/field_stats_types'; -import { buildSamplerAggregation } from '../../utils/field_stats_utils'; import { getQueryWithParams } from '../get_query_with_params'; export const getKeywordFieldStatsRequest = ( @@ -24,7 +23,7 @@ export const getKeywordFieldStatsRequest = ( ): estypes.SearchRequest => { const query = getQueryWithParams({ params, termFilters }); - const { index, samplerShardSize } = params; + const { index } = params; const size = 0; const aggs: Aggs = { @@ -32,23 +31,19 @@ export const getKeywordFieldStatsRequest = ( terms: { field: fieldName, size: 10, - order: { - _count: 'desc', - }, }, }, }; const searchBody = { query, - aggs: { - sample: buildSamplerAggregation(aggs, samplerShardSize), - }, + aggs, }; return { index, size, + track_total_hits: false, body: searchBody, }; }; @@ -66,19 +61,16 @@ export const fetchKeywordFieldStats = async ( ); const { body } = await esClient.search(request); const aggregations = body.aggregations as { - sample: { - sampled_top: estypes.AggregationsTermsAggregate; - }; + sampled_top: estypes.AggregationsTermsAggregate; }; - const topValues: TopValueBucket[] = - aggregations?.sample.sampled_top?.buckets ?? []; + const topValues: TopValueBucket[] = aggregations?.sampled_top?.buckets ?? []; const stats = { fieldName: field.fieldName, topValues, topValuesSampleSize: topValues.reduce( (acc, curr) => acc + curr.doc_count, - aggregations.sample.sampled_top?.sum_other_doc_count ?? 0 + aggregations.sampled_top?.sum_other_doc_count ?? 0 ), }; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_numeric_field_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_numeric_field_stats.ts index 21e6559fdda25..197ed66c4fe70 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_numeric_field_stats.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/get_numeric_field_stats.ts @@ -6,7 +6,7 @@ */ import { ElasticsearchClient } from 'kibana/server'; -import { find, get } from 'lodash'; +import { get } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { NumericFieldStats, @@ -16,10 +16,6 @@ import { } from '../../../../../common/correlations/field_stats_types'; import { FieldValuePair } from '../../../../../common/correlations/types'; import { getQueryWithParams } from '../get_query_with_params'; -import { buildSamplerAggregation } from '../../utils/field_stats_utils'; - -// Only need 50th percentile for the median -const PERCENTILES = [50]; export const getNumericFieldStatsRequest = ( params: FieldStatsCommonRequestParams, @@ -29,9 +25,8 @@ export const getNumericFieldStatsRequest = ( const query = getQueryWithParams({ params, termFilters }); const size = 0; - const { index, samplerShardSize } = params; + const { index } = params; - const percents = PERCENTILES; const aggs: Aggs = { sampled_field_stats: { filter: { exists: { field: fieldName } }, @@ -41,13 +36,6 @@ export const getNumericFieldStatsRequest = ( }, }, }, - sampled_percentiles: { - percentiles: { - field: fieldName, - percents, - keyed: false, - }, - }, sampled_top: { terms: { field: fieldName, @@ -61,14 +49,13 @@ export const getNumericFieldStatsRequest = ( const searchBody = { query, - aggs: { - sample: buildSamplerAggregation(aggs, samplerShardSize), - }, + aggs, }; return { index, size, + track_total_hits: false, body: searchBody, }; }; @@ -87,19 +74,15 @@ export const fetchNumericFieldStats = async ( const { body } = await esClient.search(request); const aggregations = body.aggregations as { - sample: { - sampled_top: estypes.AggregationsTermsAggregate; - sampled_percentiles: estypes.AggregationsHdrPercentilesAggregate; - sampled_field_stats: { - doc_count: number; - actual_stats: estypes.AggregationsStatsAggregate; - }; + sampled_top: estypes.AggregationsTermsAggregate; + sampled_field_stats: { + doc_count: number; + actual_stats: estypes.AggregationsStatsAggregate; }; }; - const docCount = aggregations?.sample.sampled_field_stats?.doc_count ?? 0; - const fieldStatsResp = - aggregations?.sample.sampled_field_stats?.actual_stats ?? {}; - const topValues = aggregations?.sample.sampled_top?.buckets ?? []; + const docCount = aggregations?.sampled_field_stats?.doc_count ?? 0; + const fieldStatsResp = aggregations?.sampled_field_stats?.actual_stats ?? {}; + const topValues = aggregations?.sampled_top?.buckets ?? []; const stats: NumericFieldStats = { fieldName: field.fieldName, @@ -110,20 +93,9 @@ export const fetchNumericFieldStats = async ( topValues, topValuesSampleSize: topValues.reduce( (acc: number, curr: TopValueBucket) => acc + curr.doc_count, - aggregations.sample.sampled_top?.sum_other_doc_count ?? 0 + aggregations.sampled_top?.sum_other_doc_count ?? 0 ), }; - if (stats.count !== undefined && stats.count > 0) { - const percentiles = aggregations?.sample.sampled_percentiles.values ?? []; - const medianPercentile: { value: number; key: number } | undefined = find( - percentiles, - { - key: 50, - } - ); - stats.median = medianPercentile !== undefined ? medianPercentile!.value : 0; - } - return stats; }; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/index.ts b/x-pack/plugins/apm/server/routes/correlations/queries/index.ts index 548127eb7647d..d2a86a20bd5c6 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/index.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/index.ts @@ -16,3 +16,4 @@ export { fetchTransactionDurationCorrelation } from './query_correlation'; export { fetchTransactionDurationCorrelationWithHistogram } from './query_correlation_with_histogram'; export { fetchTransactionDurationHistogramRangeSteps } from './query_histogram_range_steps'; export { fetchTransactionDurationRanges } from './query_ranges'; +export { fetchFieldValueFieldStats } from './field_stats/get_field_value_stats'; diff --git a/x-pack/plugins/apm/server/routes/correlations/route.ts b/x-pack/plugins/apm/server/routes/correlations/route.ts index b02a6fbc6b7a6..377fedf9d1813 100644 --- a/x-pack/plugins/apm/server/routes/correlations/route.ts +++ b/x-pack/plugins/apm/server/routes/correlations/route.ts @@ -19,6 +19,7 @@ import { fetchSignificantCorrelations, fetchTransactionDurationFieldCandidates, fetchTransactionDurationFieldValuePairs, + fetchFieldValueFieldStats, } from './queries'; import { fetchFieldsStats } from './queries/field_stats/get_fields_stats'; @@ -77,12 +78,12 @@ const fieldStatsRoute = createApmServerRoute({ transactionName: t.string, transactionType: t.string, }), - environmentRt, - kueryRt, - rangeRt, t.type({ fieldsToSample: t.array(t.string), }), + environmentRt, + kueryRt, + rangeRt, ]), }), options: { tags: ['access:apm'] }, @@ -112,6 +113,51 @@ const fieldStatsRoute = createApmServerRoute({ }, }); +const fieldValueStatsRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/correlations/field_value_stats', + params: t.type({ + query: t.intersection([ + t.partial({ + serviceName: t.string, + transactionName: t.string, + transactionType: t.string, + }), + environmentRt, + kueryRt, + rangeRt, + t.type({ + fieldName: t.string, + fieldValue: t.union([t.string, t.number]), + }), + ]), + }), + options: { tags: ['access:apm'] }, + handler: async (resources) => { + const { context } = resources; + if (!isActivePlatinumLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } + + const { indices } = await setupRequest(resources); + const esClient = resources.context.core.elasticsearch.client.asCurrentUser; + + const { fieldName, fieldValue, ...params } = resources.params.query; + + return withApmSpan( + 'get_correlations_field_value_stats', + async () => + await fetchFieldValueFieldStats( + esClient, + { + ...params, + index: indices.transaction, + }, + { fieldName, fieldValue } + ) + ); + }, +}); + const fieldValuePairsRoute = createApmServerRoute({ endpoint: 'POST /internal/apm/correlations/field_value_pairs', params: t.type({ @@ -252,5 +298,6 @@ export const correlationsRouteRepository = createApmServerRouteRepository() .add(pValuesRoute) .add(fieldCandidatesRoute) .add(fieldStatsRoute) + .add(fieldValueStatsRoute) .add(fieldValuePairsRoute) .add(significantCorrelationsRoute); diff --git a/x-pack/plugins/apm/server/routes/correlations/utils/field_stats_utils.ts b/x-pack/plugins/apm/server/routes/correlations/utils/field_stats_utils.ts index 7f98f771c50e2..a60622583781b 100644 --- a/x-pack/plugins/apm/server/routes/correlations/utils/field_stats_utils.ts +++ b/x-pack/plugins/apm/server/routes/correlations/utils/field_stats_utils.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - /* * Contains utility functions for building and processing queries. */ @@ -38,22 +36,3 @@ export function buildBaseFilterCriteria( return filterCriteria; } - -// Wraps the supplied aggregations in a sampler aggregation. -// A supplied samplerShardSize (the shard_size parameter of the sampler aggregation) -// of less than 1 indicates no sampling, and the aggs are returned as-is. -export function buildSamplerAggregation( - aggs: any, - samplerShardSize: number -): estypes.AggregationsAggregationContainer { - if (samplerShardSize < 1) { - return aggs; - } - - return { - sampler: { - shard_size: samplerShardSize, - }, - aggs, - }; -} diff --git a/x-pack/plugins/apm/server/routes/data_view/create_static_data_view.test.ts b/x-pack/plugins/apm/server/routes/data_view/create_static_data_view.test.ts index 7d345b5e3bec1..cdea5cd43f02f 100644 --- a/x-pack/plugins/apm/server/routes/data_view/create_static_data_view.test.ts +++ b/x-pack/plugins/apm/server/routes/data_view/create_static_data_view.test.ts @@ -36,7 +36,7 @@ describe('createStaticDataView', () => { const savedObjectsClient = getMockSavedObjectsClient('apm-*'); await createStaticDataView({ setup, - config: { autocreateApmIndexPattern: false } as APMConfig, + config: { autoCreateApmDataView: false } as APMConfig, savedObjectsClient, spaceId: 'default', }); @@ -53,7 +53,7 @@ describe('createStaticDataView', () => { await createStaticDataView({ setup, - config: { autocreateApmIndexPattern: true } as APMConfig, + config: { autoCreateApmDataView: true } as APMConfig, savedObjectsClient, spaceId: 'default', }); @@ -70,7 +70,7 @@ describe('createStaticDataView', () => { await createStaticDataView({ setup, - config: { autocreateApmIndexPattern: true } as APMConfig, + config: { autoCreateApmDataView: true } as APMConfig, savedObjectsClient, spaceId: 'default', }); @@ -90,7 +90,7 @@ describe('createStaticDataView', () => { await createStaticDataView({ setup, - config: { autocreateApmIndexPattern: true } as APMConfig, + config: { autoCreateApmDataView: true } as APMConfig, savedObjectsClient, spaceId: 'default', }); @@ -117,7 +117,7 @@ describe('createStaticDataView', () => { await createStaticDataView({ setup, - config: { autocreateApmIndexPattern: true } as APMConfig, + config: { autoCreateApmDataView: true } as APMConfig, savedObjectsClient, spaceId: 'default', }); diff --git a/x-pack/plugins/apm/server/routes/data_view/create_static_data_view.ts b/x-pack/plugins/apm/server/routes/data_view/create_static_data_view.ts index 20b3d3117dd9f..665f9ca3e96eb 100644 --- a/x-pack/plugins/apm/server/routes/data_view/create_static_data_view.ts +++ b/x-pack/plugins/apm/server/routes/data_view/create_static_data_view.ts @@ -31,8 +31,8 @@ export async function createStaticDataView({ spaceId?: string; }): Promise { return withApmSpan('create_static_data_view', async () => { - // don't autocreate APM data view if it's been disabled via the config - if (!config.autocreateApmIndexPattern) { + // don't auto-create APM data view if it's been disabled via the config + if (!config.autoCreateApmDataView) { return false; } diff --git a/x-pack/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts b/x-pack/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts index d32e751a6ca99..e460991029915 100644 --- a/x-pack/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts +++ b/x-pack/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts @@ -110,7 +110,6 @@ export async function getErrorGroupMainStatistics({ ); return ( - // @ts-ignore 4.3.5 upgrade - Expression produces a union type that is too complex to represent. ts(2590) response.aggregations?.error_groups.buckets.map((bucket) => ({ groupId: bucket.key as string, name: getErrorName(bucket.sample.hits.hits[0]._source), diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts index 792bc0463aa15..a5fcececad1cc 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts @@ -120,7 +120,6 @@ export async function getServiceAnomalies({ const relevantBuckets = uniqBy( sortBy( // make sure we only return data for jobs that are available in this space - // @ts-ignore 4.3.5 upgrade typedAnomalyResponse.aggregations?.services.buckets.filter((bucket) => jobIds.includes(bucket.key.jobId as string) ) ?? [], diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_map_backend_node_info.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_map_backend_node_info.ts index 6922fc04f2e71..995aae9372554 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_map_backend_node_info.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_map_backend_node_info.ts @@ -21,6 +21,7 @@ import { Setup } from '../../lib/helpers/setup_request'; import { getBucketSize } from '../../lib/helpers/get_bucket_size'; import { getFailedTransactionRateTimeSeries } from '../../lib/helpers/transaction_error_rate'; import { NodeStats } from '../../../common/service_map'; +import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms'; interface Options { setup: Setup; @@ -28,6 +29,7 @@ interface Options { backendName: string; start: number; end: number; + offset?: string; } export function getServiceMapBackendNodeInfo({ @@ -36,11 +38,21 @@ export function getServiceMapBackendNodeInfo({ setup, start, end, + offset, }: Options): Promise { return withApmSpan('get_service_map_backend_node_stats', async () => { const { apmEventClient } = setup; + const { offsetInMs, startWithOffset, endWithOffset } = getOffsetInMs({ + start, + end, + offset, + }); - const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); + const { intervalString } = getBucketSize({ + start: startWithOffset, + end: endWithOffset, + numBuckets: 20, + }); const subAggs = { latency_sum: { @@ -66,7 +78,7 @@ export function getServiceMapBackendNodeInfo({ bool: { filter: [ { term: { [SPAN_DESTINATION_SERVICE_RESOURCE]: backendName } }, - ...rangeQuery(start, end), + ...rangeQuery(startWithOffset, endWithOffset), ...environmentQuery(environment), ], }, @@ -78,7 +90,7 @@ export function getServiceMapBackendNodeInfo({ field: '@timestamp', fixed_interval: intervalString, min_doc_count: 0, - extended_bounds: { min: start, max: end }, + extended_bounds: { min: startWithOffset, max: endWithOffset }, }, aggs: subAggs, }, @@ -95,8 +107,8 @@ export function getServiceMapBackendNodeInfo({ const avgFailedTransactionsRate = failedTransactionsRateCount / count; const latency = latencySum / count; const throughput = calculateThroughputWithRange({ - start, - end, + start: startWithOffset, + end: endWithOffset, value: count, }); @@ -116,7 +128,7 @@ export function getServiceMapBackendNodeInfo({ timeseries: response.aggregations?.timeseries ? getFailedTransactionRateTimeSeries( response.aggregations.timeseries.buckets - ) + ).map(({ x, y }) => ({ x: x + offsetInMs, y })) : undefined, }, transactionStats: { @@ -125,7 +137,7 @@ export function getServiceMapBackendNodeInfo({ timeseries: response.aggregations?.timeseries.buckets.map( (bucket) => { return { - x: bucket.key, + x: bucket.key + offsetInMs, y: calculateThroughputWithRange({ start, end, @@ -139,7 +151,7 @@ export function getServiceMapBackendNodeInfo({ value: latency, timeseries: response.aggregations?.timeseries.buckets.map( (bucket) => ({ - x: bucket.key, + x: bucket.key + offsetInMs, y: bucket.latency_sum.value, }) ), diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts index 545fb4dbc4606..ec6c13de76fb1 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts @@ -22,6 +22,7 @@ import { TRANSACTION_REQUEST, } from '../../../common/transaction_types'; import { environmentQuery } from '../../../common/utils/environment_query'; +import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms'; import { getBucketSizeForAggregatedTransactions } from '../../lib/helpers/get_bucket_size_for_aggregated_transactions'; import { Setup } from '../../lib/helpers/setup_request'; import { @@ -43,6 +44,7 @@ interface Options { searchAggregatedTransactions: boolean; start: number; end: number; + offset?: string; } interface TaskParameters { @@ -57,6 +59,7 @@ interface TaskParameters { intervalString: string; bucketSize: number; numBuckets: number; + offsetInMs: number; } export function getServiceMapServiceNodeInfo({ @@ -66,11 +69,18 @@ export function getServiceMapServiceNodeInfo({ searchAggregatedTransactions, start, end, + offset, }: Options): Promise { return withApmSpan('get_service_map_node_stats', async () => { + const { offsetInMs, startWithOffset, endWithOffset } = getOffsetInMs({ + start, + end, + offset, + }); + const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, - ...rangeQuery(start, end), + ...rangeQuery(startWithOffset, endWithOffset), ...environmentQuery(environment), ]; @@ -90,11 +100,12 @@ export function getServiceMapServiceNodeInfo({ minutes, serviceName, setup, - start, - end, + start: startWithOffset, + end: endWithOffset, intervalString, bucketSize, numBuckets, + offsetInMs, }; const [failedTransactionsRate, transactionStats, cpuUsage, memoryUsage] = @@ -121,6 +132,7 @@ async function getFailedTransactionsRateStats({ start, end, numBuckets, + offsetInMs, }: TaskParameters): Promise { return withApmSpan('get_error_rate_for_service_map_node', async () => { const { average, timeseries } = await getFailedTransactionRate({ @@ -133,7 +145,10 @@ async function getFailedTransactionsRateStats({ kuery: '', numBuckets, }); - return { value: average, timeseries }; + return { + value: average, + timeseries: timeseries.map(({ x, y }) => ({ x: x + offsetInMs, y })), + }; }); } @@ -145,6 +160,7 @@ async function getTransactionStats({ start, end, intervalString, + offsetInMs, }: TaskParameters): Promise { const { apmEventClient } = setup; @@ -204,7 +220,7 @@ async function getTransactionStats({ latency: { value: response.aggregations?.duration.value ?? null, timeseries: response.aggregations?.timeseries.buckets.map((bucket) => ({ - x: bucket.key, + x: bucket.key + offsetInMs, y: bucket.latency.value, })), }, @@ -212,7 +228,7 @@ async function getTransactionStats({ value: totalRequests > 0 ? totalRequests / minutes : null, timeseries: response.aggregations?.timeseries.buckets.map((bucket) => { return { - x: bucket.key, + x: bucket.key + offsetInMs, y: bucket.doc_count ?? 0, }; }), @@ -226,6 +242,7 @@ async function getCpuStats({ intervalString, start, end, + offsetInMs, }: TaskParameters): Promise { const { apmEventClient } = setup; @@ -266,7 +283,7 @@ async function getCpuStats({ return { value: response.aggregations?.avgCpuUsage.value ?? null, timeseries: response.aggregations?.timeseries.buckets.map((bucket) => ({ - x: bucket.key, + x: bucket.key + offsetInMs, y: bucket.cpuAvg.value, })), }; @@ -278,6 +295,7 @@ function getMemoryStats({ intervalString, start, end, + offsetInMs, }: TaskParameters) { return withApmSpan('get_memory_stats_for_service_map_node', async () => { const { apmEventClient } = setup; @@ -324,7 +342,7 @@ function getMemoryStats({ return { value: response.aggregations?.avgMemoryUsage.value ?? null, timeseries: response.aggregations?.timeseries.buckets.map((bucket) => ({ - x: bucket.key, + x: bucket.key + offsetInMs, y: bucket.memoryAvg.value, })), }; diff --git a/x-pack/plugins/apm/server/routes/service_map/route.ts b/x-pack/plugins/apm/server/routes/service_map/route.ts index 97d0c01ed6a44..6b002e913204b 100644 --- a/x-pack/plugins/apm/server/routes/service_map/route.ts +++ b/x-pack/plugins/apm/server/routes/service_map/route.ts @@ -17,7 +17,7 @@ import { getServiceMapBackendNodeInfo } from './get_service_map_backend_node_inf import { getServiceMapServiceNodeInfo } from './get_service_map_service_node_info'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { createApmServerRouteRepository } from '../apm_routes/create_apm_server_route_repository'; -import { environmentRt, rangeRt } from '../default_api_types'; +import { environmentRt, offsetRt, rangeRt } from '../default_api_types'; const serviceMapRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/service-map', @@ -75,7 +75,7 @@ const serviceMapServiceNodeRoute = createApmServerRoute({ path: t.type({ serviceName: t.string, }), - query: t.intersection([environmentRt, rangeRt]), + query: t.intersection([environmentRt, rangeRt, offsetRt]), }), options: { tags: ['access:apm'] }, handler: async (resources) => { @@ -91,7 +91,7 @@ const serviceMapServiceNodeRoute = createApmServerRoute({ const { path: { serviceName }, - query: { environment, start, end }, + query: { environment, start, end, offset }, } = params; const searchAggregatedTransactions = await getSearchAggregatedTransactions({ @@ -102,14 +102,23 @@ const serviceMapServiceNodeRoute = createApmServerRoute({ kuery: '', }); - return getServiceMapServiceNodeInfo({ + const commonProps = { environment, setup, serviceName, searchAggregatedTransactions, start, end, - }); + }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + getServiceMapServiceNodeInfo(commonProps), + offset + ? getServiceMapServiceNodeInfo({ ...commonProps, offset }) + : undefined, + ]); + + return { currentPeriod, previousPeriod }; }, }); @@ -120,6 +129,7 @@ const serviceMapBackendNodeRoute = createApmServerRoute({ t.type({ backendName: t.string }), environmentRt, rangeRt, + offsetRt, ]), }), options: { tags: ['access:apm'] }, @@ -135,16 +145,19 @@ const serviceMapBackendNodeRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { - query: { backendName, environment, start, end }, + query: { backendName, environment, start, end, offset }, } = params; - return getServiceMapBackendNodeInfo({ - environment, - setup, - backendName, - start, - end, - }); + const commonProps = { environment, setup, backendName, start, end }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + getServiceMapBackendNodeInfo(commonProps), + offset + ? getServiceMapBackendNodeInfo({ ...commonProps, offset }) + : undefined, + ]); + + return { currentPeriod, previousPeriod }; }, }); diff --git a/x-pack/plugins/apm/server/routes/service_nodes/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/routes/service_nodes/__snapshots__/queries.test.ts.snap index e0591a90b1c19..5022521c46914 100644 --- a/x-pack/plugins/apm/server/routes/service_nodes/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/service_nodes/__snapshots__/queries.test.ts.snap @@ -17,7 +17,7 @@ Object { }, "host": Object { "terms": Object { - "field": "host.hostname", + "field": "host.name", "size": 1, }, }, @@ -74,7 +74,7 @@ Object { }, "host": Object { "terms": Object { - "field": "host.hostname", + "field": "host.name", "size": 1, }, }, @@ -145,7 +145,7 @@ Object { "top_metrics": Object { "metrics": Array [ Object { - "field": "host.hostname", + "field": "host.name", }, ], "sort": Object { diff --git a/x-pack/plugins/apm/server/routes/service_nodes/get_service_nodes.ts b/x-pack/plugins/apm/server/routes/service_nodes/get_service_nodes.ts index ebd56cb9768ce..58c105289be9c 100644 --- a/x-pack/plugins/apm/server/routes/service_nodes/get_service_nodes.ts +++ b/x-pack/plugins/apm/server/routes/service_nodes/get_service_nodes.ts @@ -109,7 +109,7 @@ const getServiceNodes = async ({ name: bucket.key as string, cpu: bucket.cpu.value, heapMemory: bucket.heapMemory.value, - hostName: bucket.latest.top?.[0]?.metrics?.['host.hostname'] as + hostName: bucket.latest.top?.[0]?.metrics?.[HOST_NAME] as | string | null | undefined, diff --git a/x-pack/plugins/apm/server/routes/services/get_service_infrastructure.ts b/x-pack/plugins/apm/server/routes/services/get_service_infrastructure.ts index cda0beb6b2d70..79d7ff4f1f41e 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_infrastructure.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_infrastructure.ts @@ -12,7 +12,7 @@ import { ProcessorEvent } from '../../../common/processor_event'; import { SERVICE_NAME, CONTAINER_ID, - HOSTNAME, + HOST_NAME, } from '../../../common/elasticsearch_fieldnames'; export const getServiceInfrastructure = async ({ @@ -57,7 +57,7 @@ export const getServiceInfrastructure = async ({ }, hostNames: { terms: { - field: HOSTNAME, + field: HOST_NAME, size: 500, }, }, diff --git a/x-pack/plugins/apm/server/routes/services/get_service_instances/get_service_instances_transaction_statistics.ts b/x-pack/plugins/apm/server/routes/services/get_service_instances/get_service_instances_transaction_statistics.ts index a9f5615acb1c0..ec081916f455d 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_instances/get_service_instances_transaction_statistics.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_instances/get_service_instances_transaction_statistics.ts @@ -168,7 +168,6 @@ export async function getServiceInstancesTransactionStatistics< const { timeseries } = serviceNodeBucket; return { serviceNodeName, - // @ts-ignore 4.3.5 upgrade - Expression produces a union type that is too complex to represent. errorRate: timeseries.buckets.map((dateBucket) => ({ x: dateBucket.key, y: dateBucket.failures.doc_count / dateBucket.doc_count, diff --git a/x-pack/plugins/apm/server/routes/services/get_services_detailed_statistics/index.ts b/x-pack/plugins/apm/server/routes/services/get_services_detailed_statistics/index.ts index 228add10184ba..d0fa24913a214 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services_detailed_statistics/index.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services_detailed_statistics/index.ts @@ -43,7 +43,7 @@ export async function getServicesDetailedStatistics({ getServiceTransactionDetailedStatistics(commonProps), offset ? getServiceTransactionDetailedStatistics({ ...commonProps, offset }) - : {}, + : Promise.resolve({}), ]); return { currentPeriod, previousPeriod }; diff --git a/x-pack/plugins/apm/server/routes/traces/calculate_impact_builder.ts b/x-pack/plugins/apm/server/routes/traces/calculate_impact_builder.ts new file mode 100644 index 0000000000000..bcbd3ac88aedd --- /dev/null +++ b/x-pack/plugins/apm/server/routes/traces/calculate_impact_builder.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function calculateImpactBuilder(sums?: Array) { + const sumValues = (sums ?? []).filter((value) => value !== null) as number[]; + + const max = Math.max(...sumValues); + const min = Math.min(...sumValues); + + return (sum: number) => + sum !== null && sum !== undefined + ? ((sum - min) / (max - min)) * 100 || 0 + : 0; +} diff --git a/x-pack/plugins/apm/server/routes/traces/get_top_traces_primary_stats.ts b/x-pack/plugins/apm/server/routes/traces/get_top_traces_primary_stats.ts new file mode 100644 index 0000000000000..7b0bb729324d7 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/traces/get_top_traces_primary_stats.ts @@ -0,0 +1,188 @@ +/* + * Copyright 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { sortBy } from 'lodash'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { withApmSpan } from '../../utils/with_apm_span'; +import { Setup } from '../../lib/helpers/setup_request'; +import { asMutableArray } from '../../../common/utils/as_mutable_array'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { calculateImpactBuilder } from './calculate_impact_builder'; +import { calculateThroughputWithRange } from '../../lib/helpers/calculate_throughput'; +import { + kqlQuery, + rangeQuery, + termQuery, +} from '../../../../observability/server'; +import { + getDurationFieldForTransactions, + getDocumentTypeFilterForTransactions, + getProcessorEventForTransactions, +} from '../../lib/helpers/transactions'; +import { + AGENT_NAME, + PARENT_ID, + SERVICE_NAME, + TRANSACTION_TYPE, + TRANSACTION_NAME, + TRANSACTION_ROOT, +} from '../../../common/elasticsearch_fieldnames'; + +export type BucketKey = Record< + typeof TRANSACTION_NAME | typeof SERVICE_NAME, + string +>; + +interface TopTracesParams { + environment: string; + kuery: string; + transactionName?: string; + searchAggregatedTransactions: boolean; + start: number; + end: number; + setup: Setup; +} +export function getTopTracesPrimaryStats({ + environment, + kuery, + transactionName, + searchAggregatedTransactions, + start, + end, + setup, +}: TopTracesParams) { + return withApmSpan('get_top_traces_primary_stats', async () => { + const response = await setup.apmEventClient.search( + 'get_transaction_group_stats', + { + apm: { + events: [ + getProcessorEventForTransactions(searchAggregatedTransactions), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + ...termQuery(TRANSACTION_NAME, transactionName), + ...getDocumentTypeFilterForTransactions( + searchAggregatedTransactions + ), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ...(searchAggregatedTransactions + ? [ + { + term: { + [TRANSACTION_ROOT]: true, + }, + }, + ] + : []), + ] as estypes.QueryDslQueryContainer[], + must_not: [ + ...(!searchAggregatedTransactions + ? [ + { + exists: { + field: PARENT_ID, + }, + }, + ] + : []), + ], + }, + }, + aggs: { + transaction_groups: { + composite: { + sources: asMutableArray([ + { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, + { + [TRANSACTION_NAME]: { + terms: { field: TRANSACTION_NAME }, + }, + }, + ] as const), + // traces overview is hardcoded to 10000 + size: 10000, + }, + aggs: { + transaction_type: { + top_metrics: { + sort: { + '@timestamp': 'desc' as const, + }, + metrics: [ + { + field: TRANSACTION_TYPE, + } as const, + { + field: AGENT_NAME, + } as const, + ], + }, + }, + avg: { + avg: { + field: getDurationFieldForTransactions( + searchAggregatedTransactions + ), + }, + }, + sum: { + sum: { + field: getDurationFieldForTransactions( + searchAggregatedTransactions + ), + }, + }, + }, + }, + }, + }, + } + ); + + const calculateImpact = calculateImpactBuilder( + response.aggregations?.transaction_groups.buckets.map( + ({ sum }) => sum.value + ) + ); + + const items = response.aggregations?.transaction_groups.buckets.map( + (bucket) => { + return { + key: bucket.key as BucketKey, + serviceName: bucket.key[SERVICE_NAME] as string, + transactionName: bucket.key[TRANSACTION_NAME] as string, + averageResponseTime: bucket.avg.value, + transactionsPerMinute: calculateThroughputWithRange({ + start, + end, + value: bucket.doc_count ?? 0, + }), + transactionType: bucket.transaction_type.top[0].metrics[ + TRANSACTION_TYPE + ] as string, + impact: calculateImpact(bucket.sum.value ?? 0), + agentName: bucket.transaction_type.top[0].metrics[ + AGENT_NAME + ] as AgentName, + }; + } + ); + + return { + // sort by impact by default so most impactful services are not cut off + items: sortBy(items, 'impact').reverse(), + }; + }); +} diff --git a/x-pack/plugins/apm/server/routes/traces/route.ts b/x-pack/plugins/apm/server/routes/traces/route.ts index 24b5faeedfc00..33f78b7bca11a 100644 --- a/x-pack/plugins/apm/server/routes/traces/route.ts +++ b/x-pack/plugins/apm/server/routes/traces/route.ts @@ -8,7 +8,7 @@ import * as t from 'io-ts'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getTraceItems } from './get_trace_items'; -import { getTopTransactionGroupList } from '../../lib/transaction_groups'; +import { getTopTracesPrimaryStats } from './get_top_traces_primary_stats'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from '../default_api_types'; import { getSearchAggregatedTransactions } from '../../lib/helpers/transactions'; @@ -33,10 +33,14 @@ const tracesRoute = createApmServerRoute({ end, }); - return getTopTransactionGroupList( - { environment, kuery, searchAggregatedTransactions, start, end }, - setup - ); + return await getTopTracesPrimaryStats({ + environment, + kuery, + setup, + searchAggregatedTransactions, + start, + end, + }); }, }); diff --git a/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts b/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts new file mode 100644 index 0000000000000..709c867377aff --- /dev/null +++ b/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Setup } from '../../lib/helpers/setup_request'; +import { getFailedTransactionRate } from '../../lib/transaction_groups/get_failed_transaction_rate'; +import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; + +export async function getFailedTransactionRatePeriods({ + environment, + kuery, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + comparisonStart, + comparisonEnd, + start, + end, +}: { + environment: string; + kuery: string; + serviceName: string; + transactionType?: string; + transactionName?: string; + setup: Setup; + searchAggregatedTransactions: boolean; + comparisonStart?: number; + comparisonEnd?: number; + start: number; + end: number; +}) { + const commonProps = { + environment, + kuery, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + }; + + const currentPeriodPromise = getFailedTransactionRate({ + ...commonProps, + start, + end, + }); + + const previousPeriodPromise = + comparisonStart && comparisonEnd + ? getFailedTransactionRate({ + ...commonProps, + start: comparisonStart, + end: comparisonEnd, + }) + : { timeseries: [], average: null }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + currentPeriodPromise, + previousPeriodPromise, + ]); + + const currentPeriodTimeseries = currentPeriod.timeseries; + + return { + currentPeriod, + previousPeriod: { + ...previousPeriod, + timeseries: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries, + previousPeriodTimeseries: previousPeriod.timeseries, + }), + }, + }; +} diff --git a/x-pack/plugins/apm/server/routes/transactions/route.ts b/x-pack/plugins/apm/server/routes/transactions/route.ts index b9db2762bce93..cad1c3b8f353b 100644 --- a/x-pack/plugins/apm/server/routes/transactions/route.ts +++ b/x-pack/plugins/apm/server/routes/transactions/route.ts @@ -19,7 +19,7 @@ import { getServiceTransactionGroupDetailedStatisticsPeriods } from '../services import { getTransactionBreakdown } from './breakdown'; import { getTransactionTraceSamples } from './trace_samples'; import { getLatencyPeriods } from './get_latency_charts'; -import { getFailedTransactionRatePeriods } from '../../lib/transaction_groups/get_failed_transaction_rate'; +import { getFailedTransactionRatePeriods } from './get_failed_transaction_rate_periods'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { createApmServerRouteRepository } from '../apm_routes/create_apm_server_route_repository'; import { diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot index b8013818ca58f..d47ecf71b2293 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset_manager.stories.storyshot @@ -109,21 +109,34 @@ exports[`Storyshots components/Assets/AssetManager no assets 1`] = ` className="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow canvasAssetManager__emptyPanel" >
-
-

- Import your assets to get started -

+
+ +
+
+
+

+ Import your assets to get started +

+
+
+
diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot b/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot index 6d782713d8fc1..8f00060a1dd1c 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot @@ -16,49 +16,61 @@ exports[`Storyshots Home/Components/Empty Prompt Empty Prompt 1`] = ` className="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusNone euiPanel--subdued euiPanel--noShadow euiPanel--noBorder" >
-
-

- Add your first workpad -

-
+ className="euiEmptyPrompt__icon" + > + +
-

- Create a new workpad, start from a template, or import a workpad JSON file by dropping it here. -

-

- New to Canvas? - - +

Add your first workpad - - . -

+

+ +
+
+

+ Create a new workpad, start from a template, or import a workpad JSON file by dropping it here. +

+

+ New to Canvas? + + + Add your first workpad + + . +

+
+ +
-
+
diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot index f019f9dc8f23d..23202a7a1fb88 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot @@ -86,36 +86,49 @@ exports[`Storyshots components/SavedElementsModal no custom elements 1`] = ` className="euiSpacer euiSpacer--l" />
-
-

- Add new elements -

-
+ className="euiEmptyPrompt__icon" + > + +
-

- Group and save workpad elements to create new elements -

+
+

+ Add new elements +

+ +
+
+

+ Group and save workpad elements to create new elements +

+
+ +
- +
diff --git a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx index dad34e6983c5d..a6bb98e9aba9b 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx @@ -91,7 +91,7 @@ export const EditorMenu: FC = ({ addElement }) => { if (embeddableInput) { const config = encode(embeddableInput); const expression = `embeddable config="${config}" - type="${factory.type}" + type="${factory.type}" | render`; addElement({ expression }); @@ -123,6 +123,7 @@ export const EditorMenu: FC = ({ addElement }) => { const factories = embeddablesService ? Array.from(embeddablesService.getEmbeddableFactories()).filter( ({ type, isEditable, canCreateNew, isContainerType }) => + // @ts-expect-error ts 4.5 upgrade isEditable() && !isContainerType && canCreateNew() && diff --git a/x-pack/plugins/canvas/shareable_runtime/webpack.config.js b/x-pack/plugins/canvas/shareable_runtime/webpack.config.js index b8074436d350b..42e96ab4471fe 100644 --- a/x-pack/plugins/canvas/shareable_runtime/webpack.config.js +++ b/x-pack/plugins/canvas/shareable_runtime/webpack.config.js @@ -188,7 +188,6 @@ module.exports = { { test: [ require.resolve('@elastic/eui/es/components/drag_and_drop'), - require.resolve('@elastic/eui/packages/react-datepicker'), require.resolve('highlight.js'), ], use: require.resolve('null-loader'), diff --git a/x-pack/plugins/canvas/storybook/storyshots.test.tsx b/x-pack/plugins/canvas/storybook/storyshots.test.tsx index e9b6d71eee274..1b05145d561f5 100644 --- a/x-pack/plugins/canvas/storybook/storyshots.test.tsx +++ b/x-pack/plugins/canvas/storybook/storyshots.test.tsx @@ -36,14 +36,6 @@ Date.now = jest.fn(() => testTime.getTime()); // Mock telemetry service jest.mock('../public/lib/ui_metric', () => ({ trackCanvasUiMetric: () => {} })); -// Mock react-datepicker dep used by eui to avoid rendering the entire large component -jest.mock('@elastic/eui/packages/react-datepicker', () => { - return { - __esModule: true, - default: 'ReactDatePicker', - }; -}); - // Mock React Portal for components that use modals, tooltips, etc // @ts-expect-error Portal mocks are notoriously difficult to type ReactDOM.createPortal = jest.fn((element) => element); diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts index e561b2f8cfb8a..69d01b0051e18 100644 --- a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Plugin } from 'unified'; import type { TimeRange } from 'src/plugins/data/common'; import { LENS_ID } from './constants'; @@ -13,8 +14,13 @@ export interface LensSerializerProps { timeRange: TimeRange; } -export const LensSerializer = ({ timeRange, attributes }: LensSerializerProps) => +const serializeLens = ({ timeRange, attributes }: LensSerializerProps) => `!{${LENS_ID}${JSON.stringify({ timeRange, attributes, })}}`; + +export const LensSerializer: Plugin = function () { + const Compiler = this.Compiler; + Compiler.prototype.visitors.lens = serializeLens; +}; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts index 0a95c9466b1ff..b9448f93d95c3 100644 --- a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts @@ -5,8 +5,14 @@ * 2.0. */ +import { Plugin } from 'unified'; export interface TimelineSerializerProps { match: string; } -export const TimelineSerializer = ({ match }: TimelineSerializerProps) => match; +const serializeTimeline = ({ match }: TimelineSerializerProps) => match; + +export const TimelineSerializer: Plugin = function () { + const Compiler = this.Compiler; + Compiler.prototype.visitors.timeline = serializeTimeline; +}; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/utils.test.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/utils.test.ts index baee979856511..516aff2300759 100644 --- a/x-pack/plugins/cases/common/utils/markdown_plugins/utils.test.ts +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/utils.test.ts @@ -18,5 +18,93 @@ describe('markdown utils', () => { const parsed = parseCommentString('hello\n'); expect(stringifyMarkdownComment(parsed)).toEqual('hello\n'); }); + + // This check ensures the version of remark-stringify supports tables. From version 9+ this is not supported by default. + it('parses and stringifies github formatted markdown correctly', () => { + const parsed = parseCommentString(`| Tables | Are | Cool | + |----------|:-------------:|------:| + | col 1 is | left-aligned | $1600 | + | col 2 is | centered | $12 | + | col 3 is | right-aligned | $1 |`); + + expect(stringifyMarkdownComment(parsed)).toMatchInlineSnapshot(` + "| Tables | Are | Cool | + | -------- | :-----------: | ----: | + | col 1 is | left-aligned | $1600 | + | col 2 is | centered | $12 | + | col 3 is | right-aligned | $1 | + " + `); + }); + + it('parses a timeline url', () => { + const timelineUrl = + '[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))'; + + const parsedNodes = parseCommentString(timelineUrl); + + expect(parsedNodes).toMatchInlineSnapshot(` + Object { + "children": Array [ + Object { + "match": "[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))", + "position": Position { + "end": Object { + "column": 138, + "line": 1, + "offset": 137, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "timeline", + }, + ], + "position": Object { + "end": Object { + "column": 138, + "line": 1, + "offset": 137, + }, + "start": Object { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "root", + } + `); + }); + + it('stringifies a timeline url', () => { + const timelineUrl = + '[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))'; + + const parsedNodes = parseCommentString(timelineUrl); + + expect(stringifyMarkdownComment(parsedNodes)).toEqual(`${timelineUrl}\n`); + }); + + it('parses a lens visualization', () => { + const lensVisualization = + '!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"TEst22","type":"lens","visualizationType":"lnsMetric","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"}}}}}},"visualization":{"layerId":"layer1","accessor":"col2"},"query":{"language":"kuery","query":""},"filters":[]},"references":[{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b247","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b247","name":"indexpattern-datasource-layer-layer1"}]}}}'; + + const parsedNodes = parseCommentString(lensVisualization); + expect(parsedNodes.children[0].type).toEqual('lens'); + }); + + it('stringifies a lens visualization', () => { + const lensVisualization = + '!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"TEst22","type":"lens","visualizationType":"lnsMetric","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"}}}}}},"visualization":{"layerId":"layer1","accessor":"col2"},"query":{"language":"kuery","query":""},"filters":[]},"references":[{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b247","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b247","name":"indexpattern-datasource-layer-layer1"}]}}}'; + + const parsedNodes = parseCommentString(lensVisualization); + + expect(stringifyMarkdownComment(parsedNodes)).toEqual(`${lensVisualization}\n`); + }); }); }); diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/utils.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/utils.ts index b6b061fcb41d9..e9bda7ae469e2 100644 --- a/x-pack/plugins/cases/common/utils/markdown_plugins/utils.ts +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/utils.ts @@ -45,20 +45,13 @@ export const parseCommentString = (comment: string) => { export const stringifyMarkdownComment = (comment: MarkdownNode) => unified() .use([ - [ - remarkStringify, - { - allowDangerousHtml: true, - handlers: { - /* - because we're using rison in the timeline url we need - to make sure that markdown parser doesn't modify the url - */ - timeline: TimelineSerializer, - lens: LensSerializer, - }, - }, - ], + [remarkStringify], + /* + because we're using rison in the timeline url we need + to make sure that markdown parser doesn't modify the url + */ + LensSerializer, + TimelineSerializer, ]) .stringify(comment); diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 9f1e2e6c6dda3..9a4af9bdc3a62 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -7,14 +7,14 @@ import React from 'react'; import { mount } from 'enzyme'; -import { act, render, waitFor } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { useForm, Form, FormHook } from '../../common/shared_imports'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { connectorsMock } from '../../containers/mock'; import { schema, FormProps } from './schema'; -import { CreateCaseForm, CreateCaseFormFields, CreateCaseFormProps } from './form'; +import { CreateCaseForm, CreateCaseFormProps } from './form'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { TestProviders } from '../../common/mock'; @@ -114,34 +114,22 @@ describe('CreateCaseForm', () => { expect(queryByText('Sync alert')).not.toBeInTheDocument(); }); - describe('CreateCaseFormFields', () => { - it('should render spinner when loading', async () => { - const wrapper = mount( - - - - ); - - await act(async () => { - globalForm.setFieldValue('title', 'title'); - globalForm.setFieldValue('description', 'description'); - globalForm.submit(); - // For some weird reason this is needed to pass the test. - // It does not do anything useful - await wrapper.find(`[data-test-subj="caseTitle"]`); - await wrapper.update(); - }); - - await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists() - ).toBeTruthy(); - }); + it('should render spinner when loading', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); + + await act(async () => { + globalForm.setFieldValue('title', 'title'); + globalForm.setFieldValue('description', 'description'); + await wrapper.find(`button[data-test-subj="create-case-submit"]`).simulate('click'); + wrapper.update(); }); + + expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts index 9e8ff1a334686..385c1c5945a11 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { createCommentsMigrations, stringifyCommentWithoutTrailingNewline } from './comments'; +import { + createCommentsMigrations, + mergeMigrationFunctionMaps, + migrateByValueLensVisualizations, + stringifyCommentWithoutTrailingNewline, +} from './comments'; import { getLensVisualizations, parseCommentString, @@ -14,84 +19,98 @@ import { import { savedObjectsServiceMock } from '../../../../../../src/core/server/mocks'; import { lensEmbeddableFactory } from '../../../../lens/server/embeddable/lens_embeddable_factory'; import { LensDocShape715 } from '../../../../lens/server'; -import { SavedObjectReference } from 'kibana/server'; +import { + SavedObjectReference, + SavedObjectsMigrationLogger, + SavedObjectUnsanitizedDoc, +} from 'kibana/server'; +import { + MigrateFunction, + MigrateFunctionsObject, +} from '../../../../../../src/plugins/kibana_utils/common'; +import { SerializableRecord } from '@kbn/utility-types'; -const migrations = createCommentsMigrations({ - lensEmbeddableFactory, -}); +describe('comments migrations', () => { + const migrations = createCommentsMigrations({ + lensEmbeddableFactory, + }); -const contextMock = savedObjectsServiceMock.createMigrationContext(); -describe('index migrations', () => { - describe('lens embeddable migrations for by value panels', () => { - describe('7.14.0 remove time zone from Lens visualization date histogram', () => { - const lensVisualizationToMigrate = { - title: 'MyRenamedOps', - description: '', - visualizationType: 'lnsXY', - state: { - datasourceStates: { - indexpattern: { - layers: { - '2': { - columns: { - '3': { - label: '@timestamp', - dataType: 'date', - operationType: 'date_histogram', - sourceField: '@timestamp', - isBucketed: true, - scale: 'interval', - params: { interval: 'auto', timeZone: 'Europe/Berlin' }, - }, - '4': { - label: '@timestamp', - dataType: 'date', - operationType: 'date_histogram', - sourceField: '@timestamp', - isBucketed: true, - scale: 'interval', - params: { interval: 'auto' }, - }, - '5': { - label: '@timestamp', - dataType: 'date', - operationType: 'my_unexpected_operation', - isBucketed: true, - scale: 'interval', - params: { timeZone: 'do not delete' }, - }, - }, - columnOrder: ['3', '4', '5'], - incompleteColumns: {}, + const contextMock = savedObjectsServiceMock.createMigrationContext(); + + const lensVisualizationToMigrate = { + title: 'MyRenamedOps', + description: '', + visualizationType: 'lnsXY', + state: { + datasourceStates: { + indexpattern: { + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, }, }, + columnOrder: ['3', '4', '5'], + incompleteColumns: {}, }, }, - visualization: { - title: 'Empty XY chart', - legend: { isVisible: true, position: 'right' }, - valueLabels: 'hide', - preferredSeriesType: 'bar_stacked', - layers: [ - { - layerId: '5ab74ddc-93ca-44e2-9857-ecf85c86b53e', - accessors: [ - '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', - 'e5efca70-edb5-4d6d-a30a-79384066987e', - '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', - ], - position: 'top', - seriesType: 'bar_stacked', - showGridlines: false, - xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', - }, + }, + }, + visualization: { + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '5ab74ddc-93ca-44e2-9857-ecf85c86b53e', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', }, - query: { query: '', language: 'kuery' }, - filters: [], - }, - }; + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('lens embeddable migrations for by value panels', () => { + describe('7.14.0 remove time zone from Lens visualization date histogram', () => { const expectedLensVisualizationMigrated = { title: 'MyRenamedOps', description: '', @@ -241,43 +260,140 @@ describe('index migrations', () => { expect((columns[2] as { params: {} }).params).toEqual({ timeZone: 'do not delete' }); }); }); + }); - describe('stringifyCommentWithoutTrailingNewline', () => { - it('removes the newline added by the markdown library when the comment did not originally have one', () => { - const originalComment = 'awesome'; - const parsedString = parseCommentString(originalComment); + describe('handles errors', () => { + interface CommentSerializable extends SerializableRecord { + comment?: string; + } - expect(stringifyCommentWithoutTrailingNewline(originalComment, parsedString)).toEqual( - 'awesome' - ); - }); + const migrationFunction: MigrateFunction = ( + comment + ) => { + throw new Error('an error'); + }; - it('leaves the newline if it was in the original comment', () => { - const originalComment = 'awesome\n'; - const parsedString = parseCommentString(originalComment); + const comment = `!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"editMode\":false,\"attributes\":${JSON.stringify( + lensVisualizationToMigrate + )}}}\n\n`; - expect(stringifyCommentWithoutTrailingNewline(originalComment, parsedString)).toEqual( - 'awesome\n' - ); - }); + const caseComment = { + type: 'cases-comments', + id: '1cefd0d0-e86d-11eb-bae5-3d065cd16a32', + attributes: { + comment, + }, + references: [], + }; - it('does not remove newlines that are not at the end of the comment', () => { - const originalComment = 'awesome\ncomment'; - const parsedString = parseCommentString(originalComment); + it('logs an error when it fails to parse invalid json', () => { + const commentMigrationFunction = migrateByValueLensVisualizations(migrationFunction, '1.0.0'); - expect(stringifyCommentWithoutTrailingNewline(originalComment, parsedString)).toEqual( - 'awesome\ncomment' - ); + const result = commentMigrationFunction(caseComment, contextMock); + // the comment should remain unchanged when there is an error + expect(result.attributes.comment).toEqual(comment); + + const log = contextMock.log as jest.Mocked; + expect(log.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to migrate comment with doc id: 1cefd0d0-e86d-11eb-bae5-3d065cd16a32 version: 8.0.0 error: an error", + Object { + "migrations": Object { + "comment": Object { + "id": "1cefd0d0-e86d-11eb-bae5-3d065cd16a32", + }, + }, + }, + ] + `); + }); + + describe('mergeMigrationFunctionMaps', () => { + it('logs an error when the passed migration functions fails', () => { + const migrationObj1 = { + '1.0.0': migrateByValueLensVisualizations(migrationFunction, '1.0.0'), + } as unknown as MigrateFunctionsObject; + + const migrationObj2 = { + '2.0.0': (doc: SavedObjectUnsanitizedDoc<{ comment?: string }>) => { + return doc; + }, + }; + + const mergedFunctions = mergeMigrationFunctionMaps(migrationObj1, migrationObj2); + mergedFunctions['1.0.0'](caseComment, contextMock); + + const log = contextMock.log as jest.Mocked; + expect(log.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to migrate comment with doc id: 1cefd0d0-e86d-11eb-bae5-3d065cd16a32 version: 8.0.0 error: an error", + Object { + "migrations": Object { + "comment": Object { + "id": "1cefd0d0-e86d-11eb-bae5-3d065cd16a32", + }, + }, + }, + ] + `); }); - it('does not remove spaces at the end of the comment', () => { - const originalComment = 'awesome '; - const parsedString = parseCommentString(originalComment); + it('it does not log an error when the migration function does not use the context', () => { + const migrationObj1 = { + '1.0.0': migrateByValueLensVisualizations(migrationFunction, '1.0.0'), + } as unknown as MigrateFunctionsObject; - expect(stringifyCommentWithoutTrailingNewline(originalComment, parsedString)).toEqual( - 'awesome ' - ); + const migrationObj2 = { + '2.0.0': (doc: SavedObjectUnsanitizedDoc<{ comment?: string }>) => { + throw new Error('2.0.0 error'); + }, + }; + + const mergedFunctions = mergeMigrationFunctionMaps(migrationObj1, migrationObj2); + + expect(() => mergedFunctions['2.0.0'](caseComment, contextMock)).toThrow(); + + const log = contextMock.log as jest.Mocked; + expect(log.error).not.toHaveBeenCalled(); }); }); }); + + describe('stringifyCommentWithoutTrailingNewline', () => { + it('removes the newline added by the markdown library when the comment did not originally have one', () => { + const originalComment = 'awesome'; + const parsedString = parseCommentString(originalComment); + + expect(stringifyCommentWithoutTrailingNewline(originalComment, parsedString)).toEqual( + 'awesome' + ); + }); + + it('leaves the newline if it was in the original comment', () => { + const originalComment = 'awesome\n'; + const parsedString = parseCommentString(originalComment); + + expect(stringifyCommentWithoutTrailingNewline(originalComment, parsedString)).toEqual( + 'awesome\n' + ); + }); + + it('does not remove newlines that are not at the end of the comment', () => { + const originalComment = 'awesome\ncomment'; + const parsedString = parseCommentString(originalComment); + + expect(stringifyCommentWithoutTrailingNewline(originalComment, parsedString)).toEqual( + 'awesome\ncomment' + ); + }); + + it('does not remove spaces at the end of the comment', () => { + const originalComment = 'awesome '; + const parsedString = parseCommentString(originalComment); + + expect(stringifyCommentWithoutTrailingNewline(originalComment, parsedString)).toEqual( + 'awesome ' + ); + }); + }); }); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts index e67e1c8b59887..0af9db13fce40 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts @@ -5,12 +5,9 @@ * 2.0. */ -import { mapValues, trimEnd } from 'lodash'; -import { SerializableRecord } from '@kbn/utility-types'; - -import { LensServerPluginSetup } from '../../../../lens/server'; +import { mapValues, trimEnd, mergeWith } from 'lodash'; +import type { SerializableRecord } from '@kbn/utility-types'; import { - mergeMigrationFunctionMaps, MigrateFunction, MigrateFunctionsObject, } from '../../../../../../src/plugins/kibana_utils/common'; @@ -19,7 +16,9 @@ import { SavedObjectSanitizedDoc, SavedObjectMigrationFn, SavedObjectMigrationMap, + SavedObjectMigrationContext, } from '../../../../../../src/core/server'; +import { LensServerPluginSetup } from '../../../../lens/server'; import { CommentType, AssociationType } from '../../../common/api'; import { isLensMarkdownNode, @@ -29,6 +28,7 @@ import { stringifyMarkdownComment, } from '../../../common/utils/markdown_plugins/utils'; import { addOwnerToSO, SanitizedCaseOwner } from '.'; +import { logError } from './utils'; interface UnsanitizedComment { comment: string; @@ -103,33 +103,41 @@ export const createCommentsMigrations = ( return mergeMigrationFunctionMaps(commentsMigrations, embeddableMigrations); }; -const migrateByValueLensVisualizations = - (migrate: MigrateFunction, version: string): SavedObjectMigrationFn<{ comment?: string }> => - (doc: SavedObjectUnsanitizedDoc<{ comment?: string }>) => { +export const migrateByValueLensVisualizations = + ( + migrate: MigrateFunction, + version: string + ): SavedObjectMigrationFn<{ comment?: string }, { comment?: string }> => + (doc: SavedObjectUnsanitizedDoc<{ comment?: string }>, context: SavedObjectMigrationContext) => { if (doc.attributes.comment == null) { return doc; } - const parsedComment = parseCommentString(doc.attributes.comment); - const migratedComment = parsedComment.children.map((comment) => { - if (isLensMarkdownNode(comment)) { - // casting here because ts complains that comment isn't serializable because LensMarkdownNode - // extends Node which has fields that conflict with SerializableRecord even though it is serializable - return migrate(comment as SerializableRecord) as LensMarkdownNode; - } + try { + const parsedComment = parseCommentString(doc.attributes.comment); + const migratedComment = parsedComment.children.map((comment) => { + if (isLensMarkdownNode(comment)) { + // casting here because ts complains that comment isn't serializable because LensMarkdownNode + // extends Node which has fields that conflict with SerializableRecord even though it is serializable + return migrate(comment as SerializableRecord) as LensMarkdownNode; + } - return comment; - }); + return comment; + }); - const migratedMarkdown = { ...parsedComment, children: migratedComment }; + const migratedMarkdown = { ...parsedComment, children: migratedComment }; - return { - ...doc, - attributes: { - ...doc.attributes, - comment: stringifyCommentWithoutTrailingNewline(doc.attributes.comment, migratedMarkdown), - }, - }; + return { + ...doc, + attributes: { + ...doc.attributes, + comment: stringifyCommentWithoutTrailingNewline(doc.attributes.comment, migratedMarkdown), + }, + }; + } catch (error) { + logError({ id: doc.id, context, error, docType: 'comment', docKey: 'comment' }); + return doc; + } }; export const stringifyCommentWithoutTrailingNewline = ( @@ -147,3 +155,23 @@ export const stringifyCommentWithoutTrailingNewline = ( // so the comment stays consistent return trimEnd(stringifiedComment, '\n'); }; + +/** + * merge function maps adds the context param from the original implementation at: + * src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.ts + * */ +export const mergeMigrationFunctionMaps = ( + // using the saved object framework types here because they include the context, this avoids type errors in our tests + obj1: SavedObjectMigrationMap, + obj2: SavedObjectMigrationMap +) => { + const customizer = (objValue: SavedObjectMigrationFn, srcValue: SavedObjectMigrationFn) => { + if (!srcValue || !objValue) { + return srcValue || objValue; + } + return (doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => + objValue(srcValue(doc, context), context); + }; + + return mergeWith({ ...obj1 }, obj2, customizer); +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts index e9ba80322c222..3d0cff814b7d4 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts @@ -7,7 +7,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { SavedObjectMigrationContext, SavedObjectSanitizedDoc } from 'kibana/server'; +import { + SavedObjectMigrationContext, + SavedObjectSanitizedDoc, + SavedObjectsMigrationLogger, +} from 'kibana/server'; import { migrationMocks } from 'src/core/server/mocks'; import { CaseUserActionAttributes } from '../../../common/api'; import { CASE_USER_ACTION_SAVED_OBJECT } from '../../../common/constants'; @@ -217,7 +221,19 @@ describe('user action migrations', () => { userActionsConnectorIdMigration(userAction, context); - expect(context.log.error).toHaveBeenCalled(); + const log = context.log as jest.Mocked; + expect(log.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to migrate user action connector with doc id: 1 version: 8.0.0 error: Unexpected token a in JSON at position 1", + Object { + "migrations": Object { + "userAction": Object { + "id": "1", + }, + }, + }, + ] + `); }); }); @@ -385,7 +401,19 @@ describe('user action migrations', () => { userActionsConnectorIdMigration(userAction, context); - expect(context.log.error).toHaveBeenCalled(); + const log = context.log as jest.Mocked; + expect(log.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to migrate user action connector with doc id: 1 version: 8.0.0 error: Unexpected token b in JSON at position 1", + Object { + "migrations": Object { + "userAction": Object { + "id": "1", + }, + }, + }, + ] + `); }); }); @@ -555,7 +583,19 @@ describe('user action migrations', () => { userActionsConnectorIdMigration(userAction, context); - expect(context.log.error).toHaveBeenCalled(); + const log = context.log as jest.Mocked; + expect(log.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to migrate user action connector with doc id: 1 version: 8.0.0 error: Unexpected token e in JSON at position 1", + Object { + "migrations": Object { + "userAction": Object { + "id": "1", + }, + }, + }, + ] + `); }); }); }); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts index a47104dfed5f7..4d8395eb189fc 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts @@ -12,13 +12,13 @@ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc, SavedObjectMigrationContext, - LogMeta, } from '../../../../../../src/core/server'; import { isPush, isUpdateConnector, isCreateConnector } from '../../../common/utils/user_actions'; import { ConnectorTypes } from '../../../common/api'; import { extractConnectorIdFromJson } from '../../services/user_actions/transform'; import { UserActionFieldType } from '../../services/user_actions/types'; +import { logError } from './utils'; interface UserActions { action_field: string[]; @@ -33,10 +33,6 @@ interface UserActionUnmigratedConnectorDocument { old_value?: string | null; } -interface UserActionLogMeta extends LogMeta { - migrations: { userAction: { id: string } }; -} - export function userActionsConnectorIdMigration( doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext @@ -50,7 +46,13 @@ export function userActionsConnectorIdMigration( try { return formatDocumentWithConnectorReferences(doc); } catch (error) { - logError(doc.id, context, error); + logError({ + id: doc.id, + context, + error, + docType: 'user action connector', + docKey: 'userAction', + }); return originalDocWithReferences; } @@ -99,19 +101,6 @@ function formatDocumentWithConnectorReferences( }; } -function logError(id: string, context: SavedObjectMigrationContext, error: Error) { - context.log.error( - `Failed to migrate user action connector doc id: ${id} version: ${context.migrationVersion} error: ${error.message}`, - { - migrations: { - userAction: { - id, - }, - }, - } - ); -} - export const userActionsMigrations = { '7.10.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => { const { action_field, new_value, old_value, ...restAttributes } = doc.attributes; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts new file mode 100644 index 0000000000000..565688cd6ac3c --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.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 { SavedObjectsMigrationLogger } from 'kibana/server'; +import { migrationMocks } from '../../../../../../src/core/server/mocks'; +import { logError } from './utils'; + +describe('migration utils', () => { + const context = migrationMocks.createContext(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('logs an error', () => { + const log = context.log as jest.Mocked; + + logError({ + id: '1', + context, + error: new Error('an error'), + docType: 'a document', + docKey: 'key', + }); + + expect(log.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to migrate a document with doc id: 1 version: 8.0.0 error: an error", + Object { + "migrations": Object { + "key": Object { + "id": "1", + }, + }, + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts new file mode 100644 index 0000000000000..993d70181974d --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogMeta, SavedObjectMigrationContext } from '../../../../../../src/core/server'; + +interface MigrationLogMeta extends LogMeta { + migrations: { + [x: string]: { + id: string; + }; + }; +} + +export function logError({ + id, + context, + error, + docType, + docKey, +}: { + id: string; + context: SavedObjectMigrationContext; + error: Error; + docType: string; + docKey: string; +}) { + context.log.error( + `Failed to migrate ${docType} with doc id: ${id} version: ${context.migrationVersion} error: ${error.message}`, + { + migrations: { + [docKey]: { + id, + }, + }, + } + ); +} diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index e71b145c438ed..81aad8bf79ccc 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -98,7 +98,7 @@ export class CloudPlugin implements Plugin { if (home) { home.environment.update({ cloud: this.isCloudEnabled }); if (this.isCloudEnabled) { - home.tutorials.setVariable('cloud', { id, baseUrl, profileUrl }); + home.tutorials.setVariable('cloud', { id, baseUrl, profileUrl, deploymentUrl }); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/index.tsx b/x-pack/plugins/cross_cluster_replication/public/app/index.tsx index ea3eb50c46089..d6dc16a55a99f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/index.tsx +++ b/x-pack/plugins/cross_cluster_replication/public/app/index.tsx @@ -8,9 +8,17 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Provider } from 'react-redux'; -import { I18nStart, ScopedHistory, ApplicationStart } from 'kibana/public'; -import { UnmountCallback } from 'src/core/public'; -import { DocLinksStart } from 'kibana/public'; +import { Observable } from 'rxjs'; + +import { + UnmountCallback, + I18nStart, + ScopedHistory, + ApplicationStart, + DocLinksStart, + CoreTheme, +} from 'src/core/public'; +import { KibanaThemeProvider } from '../shared_imports'; import { init as initBreadcrumbs, SetBreadcrumbs } from './services/breadcrumbs'; import { init as initDocumentation } from './services/documentation_links'; import { App } from './app'; @@ -20,13 +28,16 @@ const renderApp = ( element: Element, I18nContext: I18nStart['Context'], history: ScopedHistory, - getUrlForApp: ApplicationStart['getUrlForApp'] + getUrlForApp: ApplicationStart['getUrlForApp'], + theme$: Observable ): UnmountCallback => { render( - - - + + + + + , element ); @@ -41,6 +52,7 @@ export async function mountApp({ docLinks, history, getUrlForApp, + theme$, }: { element: Element; setBreadcrumbs: SetBreadcrumbs; @@ -48,11 +60,12 @@ export async function mountApp({ docLinks: DocLinksStart; history: ScopedHistory; getUrlForApp: ApplicationStart['getUrlForApp']; + theme$: Observable; }): Promise { // Import and initialize additional services here instead of in plugin.ts to reduce the size of the // initial bundle as much as possible. initBreadcrumbs(setBreadcrumbs); initDocumentation(docLinks); - return renderApp(element, I18nContext, history, getUrlForApp); + return renderApp(element, I18nContext, history, getUrlForApp, theme$); } diff --git a/x-pack/plugins/cross_cluster_replication/public/plugin.ts b/x-pack/plugins/cross_cluster_replication/public/plugin.ts index a45862d46beeb..bc2546bdacb2a 100644 --- a/x-pack/plugins/cross_cluster_replication/public/plugin.ts +++ b/x-pack/plugins/cross_cluster_replication/public/plugin.ts @@ -41,7 +41,7 @@ export class CrossClusterReplicationPlugin implements Plugin { id: MANAGEMENT_ID, title: PLUGIN.TITLE, order: 6, - mount: async ({ element, setBreadcrumbs, history }) => { + mount: async ({ element, setBreadcrumbs, history, theme$ }) => { const { mountApp } = await import('./app'); const [coreStart] = await getStartServices(); @@ -61,6 +61,7 @@ export class CrossClusterReplicationPlugin implements Plugin { docLinks, history, getUrlForApp, + theme$, }); return () => { diff --git a/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts b/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts index 38838968ad212..f850e054f9667 100644 --- a/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts +++ b/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts @@ -13,4 +13,6 @@ export { PageLoading, } from '../../../../src/plugins/es_ui_shared/public'; +export { KibanaThemeProvider } from '../../../../src/plugins/kibana_react/public'; + export { APP_WRAPPER_CLASS } from '../../../../src/core/public'; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 79f34c13d511c..2cdbc76504fb4 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -230,7 +230,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon type: string, id: string, attributes: Partial, - options?: SavedObjectsUpdateOptions + options?: SavedObjectsUpdateOptions ) { if (!this.options.service.isRegistered(type)) { return await this.options.baseClient.update(type, id, attributes, options); diff --git a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts index dd6dd58d02f70..bbb08d0ac2b66 100644 --- a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts +++ b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts @@ -8,6 +8,7 @@ export const DEFAULT_INITIAL_APP_DATA = { kibanaVersion: '7.16.0', enterpriseSearchVersion: '7.16.0', + errorConnectingMessage: '', readOnlyMode: false, searchOAuth: { clientId: 'someUID', diff --git a/x-pack/plugins/enterprise_search/common/types/index.ts b/x-pack/plugins/enterprise_search/common/types/index.ts index 57fe3f3807783..17b3eb17d31bd 100644 --- a/x-pack/plugins/enterprise_search/common/types/index.ts +++ b/x-pack/plugins/enterprise_search/common/types/index.ts @@ -17,6 +17,7 @@ import { export interface InitialAppData { enterpriseSearchVersion?: string; kibanaVersion?: string; + errorConnectingMessage?: string; readOnlyMode?: boolean; searchOAuth?: SearchOAuth; configuredLimits?: ConfiguredLimits; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx index f8511d1e2ef14..f953e8e0fce39 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx @@ -64,7 +64,14 @@ export const AddDomainFlyout: React.FC = () => { - }> + + + + + } + > {i18n.translate( 'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.description', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx index 440e29e6d3002..4d72b854bddfb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx @@ -12,17 +12,22 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { useValues } from 'kea'; + import { getPageHeaderActions } from '../../../test_helpers'; import { AddDomainFlyout } from './components/add_domain/add_domain_flyout'; import { AddDomainForm } from './components/add_domain/add_domain_form'; +import { AddDomainFormErrors } from './components/add_domain/add_domain_form_errors'; import { AddDomainFormSubmitButton } from './components/add_domain/add_domain_form_submit_button'; +import { AddDomainLogic } from './components/add_domain/add_domain_logic'; import { CrawlDetailsFlyout } from './components/crawl_details_flyout'; import { CrawlRequestsTable } from './components/crawl_requests_table'; import { CrawlerStatusBanner } from './components/crawler_status_banner'; import { CrawlerStatusIndicator } from './components/crawler_status_indicator/crawler_status_indicator'; import { DomainsTable } from './components/domains_table'; import { ManageCrawlsPopover } from './components/manage_crawls_popover/manage_crawls_popover'; +import { CrawlerLogic } from './crawler_logic'; import { CrawlerOverview } from './crawler_overview'; import { CrawlerDomainFromServer, @@ -191,4 +196,23 @@ describe('CrawlerOverview', () => { expect(wrapper.find(CrawlDetailsFlyout)).toHaveLength(1); }); + + it('contains a AddDomainFormErrors when there are errors', () => { + const errors = ['Domain name already exists']; + + (useValues as jest.Mock).mockImplementation((logic) => { + switch (logic) { + case AddDomainLogic: + return { errors }; + case CrawlerLogic: + return { ...mockValues, domains: [], events: [] }; + default: + return {}; + } + }); + + const wrapper = shallow(); + + expect(wrapper.find(AddDomainFormErrors)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx index c84deb3cb0c99..c68e75790f073 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -19,7 +19,9 @@ import { AppSearchPageTemplate } from '../layout'; import { AddDomainFlyout } from './components/add_domain/add_domain_flyout'; import { AddDomainForm } from './components/add_domain/add_domain_form'; +import { AddDomainFormErrors } from './components/add_domain/add_domain_form_errors'; import { AddDomainFormSubmitButton } from './components/add_domain/add_domain_form_submit_button'; +import { AddDomainLogic } from './components/add_domain/add_domain_logic'; import { CrawlDetailsFlyout } from './components/crawl_details_flyout'; import { CrawlRequestsTable } from './components/crawl_requests_table'; import { CrawlerStatusBanner } from './components/crawler_status_banner'; @@ -31,6 +33,7 @@ import { CrawlerLogic } from './crawler_logic'; export const CrawlerOverview: React.FC = () => { const { events, dataLoading, domains } = useValues(CrawlerLogic); + const { errors: addDomainErrors } = useValues(AddDomainLogic); return ( {

+ {addDomainErrors && ( + <> + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.test.tsx index 9ec3fdda63656..be17bfaeb7127 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.test.tsx @@ -15,8 +15,10 @@ import { ErrorConnecting } from './'; describe('ErrorConnecting', () => { it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + const errorStatePrompt = wrapper.find(ErrorStatePrompt); + expect(errorStatePrompt).toHaveLength(1); + expect(errorStatePrompt.prop('errorConnectingMessage')).toEqual('I am an error'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx index 84dcb07a07474..a01fb264935c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx @@ -13,14 +13,16 @@ import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -export const ErrorConnecting: React.FC = () => { +export const ErrorConnecting: React.FC<{ errorConnectingMessage?: string }> = ({ + errorConnectingMessage, +}) => { return ( <> - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 2f415840a6c4a..2ffb1f80a3d32 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -57,9 +57,11 @@ describe('AppSearch', () => { it('renders ErrorConnecting when Enterprise Search is unavailable', () => { setMockValues({ errorConnecting: true }); - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find(ErrorConnecting)).toHaveLength(1); + const errorConnection = wrapper.find(ErrorConnecting); + expect(errorConnection).toHaveLength(1); + expect(errorConnection.prop('errorConnectingMessage')).toEqual('I am an error'); }); it('renders AppSearchConfigured when config.host is set & available', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 027a4dbee5ef6..605d82d2af601 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -45,7 +45,7 @@ import { export const AppSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); const { errorConnecting } = useValues(HttpLogic); - const { enterpriseSearchVersion, kibanaVersion } = props; + const { enterpriseSearchVersion, kibanaVersion, errorConnectingMessage } = props; const incompatibleVersions = isVersionMismatch(enterpriseSearchVersion, kibanaVersion); const showView = () => { @@ -59,7 +59,7 @@ export const AppSearch: React.FC = (props) => { /> ); } else if (errorConnecting) { - return ; + return ; } return )} />; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx index 9ec3fdda63656..be17bfaeb7127 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx @@ -15,8 +15,10 @@ import { ErrorConnecting } from './'; describe('ErrorConnecting', () => { it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + const errorStatePrompt = wrapper.find(ErrorStatePrompt); + expect(errorStatePrompt).toHaveLength(1); + expect(errorStatePrompt.prop('errorConnectingMessage')).toEqual('I am an error'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx index 979847b4cf1c6..f9ffd6c992426 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx @@ -12,10 +12,12 @@ import { KibanaPageTemplate } from '../../../../../../../../src/plugins/kibana_r import { ErrorStatePrompt } from '../../../shared/error_state'; import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -export const ErrorConnecting: React.FC = () => ( +export const ErrorConnecting: React.FC<{ errorConnectingMessage?: string }> = ({ + errorConnectingMessage, +}) => ( - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx index 7b5c748b013e5..a366057797925 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx @@ -37,10 +37,12 @@ describe('EnterpriseSearch', () => { errorConnecting: true, config: { host: 'localhost' }, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(VersionMismatchPage)).toHaveLength(0); - expect(wrapper.find(ErrorConnecting)).toHaveLength(1); + const errorConnecting = wrapper.find(ErrorConnecting); + expect(errorConnecting).toHaveLength(1); + expect(errorConnecting.prop('errorConnectingMessage')).toEqual('I am an error'); expect(wrapper.find(ProductSelector)).toHaveLength(0); setMockValues({ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx index 81aa587e3a133..ded5909a0fa43 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx @@ -26,6 +26,7 @@ export const EnterpriseSearch: React.FC = ({ workplaceSearch, enterpriseSearchVersion, kibanaVersion, + errorConnectingMessage, }) => { const { errorConnecting } = useValues(HttpLogic); const { config } = useValues(KibanaLogic); @@ -45,7 +46,7 @@ export const EnterpriseSearch: React.FC = ({ /> ); } else if (showErrorConnecting) { - return ; + return ; } return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx index e8f0816de5225..2d21ea7c61444 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx @@ -5,20 +5,48 @@ * 2.0. */ -import '../../__mocks__/kea_logic'; +import { setMockValues } from '../../__mocks__/kea_logic'; import React from 'react'; -import { shallow } from 'enzyme'; - -import { EuiEmptyPrompt } from '@elastic/eui'; +import { mountWithIntl } from '../../test_helpers'; import { ErrorStatePrompt } from './'; describe('ErrorState', () => { - it('renders', () => { - const wrapper = shallow(); + const values = { + config: {}, + cloud: { isCloudEnabled: true }, + }; + + beforeAll(() => { + setMockValues(values); + }); + + it('renders a cloud specific error on cloud deployments', () => { + setMockValues({ + ...values, + cloud: { isCloudEnabled: true }, + }); + const wrapper = mountWithIntl(); + + expect(wrapper.find('[data-test-subj="CloudError"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="SelfManagedError"]').exists()).toBe(false); + }); + + it('renders a different error if not a cloud deployment', () => { + setMockValues({ + ...values, + cloud: { isCloudEnabled: false }, + }); + const wrapper = mountWithIntl(); + + expect(wrapper.find('[data-test-subj="CloudError"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="SelfManagedError"]').exists()).toBe(true); + }); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + it('renders an error message', () => { + const wrapper = mountWithIntl(); + expect(wrapper.text()).toContain('I am an error'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx index eff483df10c7f..fea43b902993d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx @@ -9,16 +9,22 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiCode, EuiLink, EuiCodeBlock } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CloudSetup } from '../../../../../cloud/public'; + import { KibanaLogic } from '../kibana'; -import { EuiButtonTo } from '../react_router_helpers'; +import { EuiButtonTo, EuiLinkTo } from '../react_router_helpers'; import './error_state_prompt.scss'; -export const ErrorStatePrompt: React.FC = () => { - const { config } = useValues(KibanaLogic); +export const ErrorStatePrompt: React.FC<{ errorConnectingMessage?: string }> = ({ + errorConnectingMessage, +}) => { + const { config, cloud } = useValues(KibanaLogic); + const isCloudEnabled = cloud.isCloudEnabled; return ( {

{config.host}, + enterpriseSearchUrl: ( + + {config.host} + + ), }} />

-
    -
  1. - config/kibana.yml, - }} - /> -
  2. -
  3. - -
  4. -
  5. - -
      -
    • - -
    • -
    • - -
    • -
    -
  6. -
  7. - [enterpriseSearch][plugins], - }} - /> -
  8. -
+ {errorConnectingMessage} + {isCloudEnabled ? cloudError(cloud) : nonCloudError()} } actions={[ @@ -103,3 +69,69 @@ export const ErrorStatePrompt: React.FC = () => { /> ); }; + +const cloudError = (cloud: Partial) => { + const deploymentUrl = cloud?.deploymentUrl; + return ( +

+ + {i18n.translate( + 'xpack.enterpriseSearch.errorConnectingState.cloudErrorMessageLinkText', + { + defaultMessage: 'Check your deployment settings', + } + )} + + ), + }} + /> +

+ ); +}; + +const nonCloudError = () => { + return ( +
    +
  1. + config/kibana.yml, + }} + /> +
  2. +
  3. + +
  4. +
  5. + +
      +
    • + +
    • +
    • + +
    • +
    +
  6. +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx index 270daf195bd38..7bf80b5ff9180 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx @@ -9,8 +9,9 @@ import React from 'react'; import { EuiLink, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; +import { docLinks } from '../../../../shared/doc_links'; + import { EXPLORE_PLATINUM_FEATURES_LINK } from '../../../constants'; -import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; interface LicenseCalloutProps { message?: string; @@ -20,7 +21,7 @@ export const LicenseCallout: React.FC = ({ message }) => { const title = ( <> {message}{' '} - + {EXPLORE_PLATINUM_FEATURES_LINK} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 7274ee8855705..9fa2c211f1667 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -51,9 +51,11 @@ describe('WorkplaceSearch', () => { it('renders ErrorState', () => { setMockValues({ errorConnecting: true }); - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find(ErrorState)).toHaveLength(1); + const errorState = wrapper.find(ErrorState); + expect(errorState).toHaveLength(1); + expect(errorState.prop('errorConnectingMessage')).toEqual('I am an error'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index e7ffabd54a88c..41ad1670019ba 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -51,7 +51,7 @@ import { SetupGuide } from './views/setup_guide'; export const WorkplaceSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); const { errorConnecting } = useValues(HttpLogic); - const { enterpriseSearchVersion, kibanaVersion } = props; + const { enterpriseSearchVersion, kibanaVersion, errorConnectingMessage } = props; const incompatibleVersions = isVersionMismatch(enterpriseSearchVersion, kibanaVersion); if (!config.host) { @@ -64,7 +64,7 @@ export const WorkplaceSearch: React.FC = (props) => { /> ); } else if (errorConnecting) { - return ; + return ; } return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 1b630a47e2f86..ee180ae52e0b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -7,8 +7,6 @@ import { generatePath } from 'react-router-dom'; -import { docLinks } from '../shared/doc_links'; - import { GITHUB_VIA_APP_SERVICE_TYPE, GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, @@ -22,35 +20,6 @@ export const LOGOUT_ROUTE = '/logout'; export const LEAVE_FEEDBACK_EMAIL = 'support@elastic.co'; export const LEAVE_FEEDBACK_URL = `mailto:${LEAVE_FEEDBACK_EMAIL}?Subject=Elastic%20Workplace%20Search%20Feedback`; -export const BOX_DOCS_URL = docLinks.workplaceSearchBox; -export const CONFLUENCE_DOCS_URL = docLinks.workplaceSearchConfluenceCloud; -export const CONFLUENCE_SERVER_DOCS_URL = docLinks.workplaceSearchConfluenceServer; -export const CUSTOM_SOURCE_DOCS_URL = docLinks.workplaceSearchCustomSources; -export const CUSTOM_API_DOCUMENT_PERMISSIONS_DOCS_URL = - docLinks.workplaceSearchCustomSourcePermissions; -export const DIFFERENT_SYNC_TYPES_DOCS_URL = docLinks.workplaceSearchIndexingSchedule; -export const DOCUMENT_PERMISSIONS_DOCS_URL = docLinks.workplaceSearchDocumentPermissions; -export const DROPBOX_DOCS_URL = docLinks.workplaceSearchDropbox; -export const ENT_SEARCH_LICENSE_MANAGEMENT = docLinks.licenseManagement; -export const EXTERNAL_IDENTITIES_DOCS_URL = docLinks.workplaceSearchExternalIdentities; -export const GETTING_STARTED_DOCS_URL = docLinks.workplaceSearchGettingStarted; -export const GITHUB_DOCS_URL = docLinks.workplaceSearchGitHub; -export const GITHUB_ENTERPRISE_DOCS_URL = docLinks.workplaceSearchGitHub; -export const GMAIL_DOCS_URL = docLinks.workplaceSearchGmail; -export const GOOGLE_DRIVE_DOCS_URL = docLinks.workplaceSearchGoogleDrive; -export const JIRA_DOCS_URL = docLinks.workplaceSearchJiraCloud; -export const JIRA_SERVER_DOCS_URL = docLinks.workplaceSearchJiraServer; -export const OBJECTS_AND_ASSETS_DOCS_URL = docLinks.workplaceSearchSynch; -export const ONEDRIVE_DOCS_URL = docLinks.workplaceSearchOneDrive; -export const PRIVATE_SOURCES_DOCS_URL = docLinks.workplaceSearchPermissions; -export const SALESFORCE_DOCS_URL = docLinks.workplaceSearchSalesforce; -export const SECURITY_DOCS_URL = docLinks.workplaceSearchSecurity; -export const SERVICENOW_DOCS_URL = docLinks.workplaceSearchServiceNow; -export const SHAREPOINT_DOCS_URL = docLinks.workplaceSearchSharePoint; -export const SLACK_DOCS_URL = docLinks.workplaceSearchSlack; -export const SYNCHRONIZATION_DOCS_URL = docLinks.workplaceSearchSynch; -export const ZENDESK_DOCS_URL = docLinks.workplaceSearchZendesk; - export const PERSONAL_PATH = '/p'; export const OAUTH_AUTHORIZE_PATH = `${PERSONAL_PATH}/oauth/authorize`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx index 167bf1af4b9b1..9b34053bfe524 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx @@ -21,13 +21,9 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { docLinks } from '../../../../../shared/doc_links'; import { EuiLinkTo, EuiButtonTo } from '../../../../../shared/react_router_helpers'; -import { - getSourcesPath, - ADD_SOURCE_PATH, - SECURITY_PATH, - PRIVATE_SOURCES_DOCS_URL, -} from '../../../../routes'; +import { getSourcesPath, ADD_SOURCE_PATH, SECURITY_PATH } from '../../../../routes'; import { CONFIG_COMPLETED_PRIVATE_SOURCES_DISABLED_LINK, @@ -126,7 +122,7 @@ export const ConfigCompleted: React.FC = ({ {CONFIG_COMPLETED_PRIVATE_SOURCES_DOCS_LINK} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx index 4682d4329a964..e794323dc169e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { CUSTOM_SOURCE_DOCS_URL } from '../../../../routes'; +import { docLinks } from '../../../../../shared/doc_links'; import { SOURCE_NAME_LABEL } from '../../constants'; @@ -63,7 +63,7 @@ export const ConfigureCustom: React.FC = ({ defaultMessage="{link} to learn more about Custom API Sources." values={{ link: ( - + {CONFIG_CUSTOM_LINK_TEXT} ), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/document_permissions_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/document_permissions_callout.tsx index 3c6980f74bcf5..d3879eabe08de 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/document_permissions_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/document_permissions_callout.tsx @@ -17,8 +17,8 @@ import { EuiText, } from '@elastic/eui'; +import { docLinks } from '../../../../../shared/doc_links'; import { EXPLORE_PLATINUM_FEATURES_LINK } from '../../../../constants'; -import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../../routes'; import { SOURCE_FEATURES_DOCUMENT_LEVEL_PERMISSIONS_FEATURE, @@ -45,7 +45,7 @@ export const DocumentPermissionsCallout: React.FC = () => { - + {EXPLORE_PLATINUM_FEATURES_LINK} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/document_permissions_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/document_permissions_field.tsx index 1b1043ecbc3d2..1cc953ee7c2ea 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/document_permissions_field.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/document_permissions_field.tsx @@ -18,7 +18,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { DOCUMENT_PERMISSIONS_DOCS_URL } from '../../../../routes'; +import { docLinks } from '../../../../../shared/doc_links'; import { LEARN_MORE_LINK } from '../../constants'; import { @@ -42,7 +42,7 @@ export const DocumentPermissionsField: React.FC = ({ setValue, }) => { const whichDocsLink = ( - + {CONNECT_WHICH_OPTION_LINK} ); @@ -64,7 +64,7 @@ export const DocumentPermissionsField: React.FC = ({ defaultMessage="Document-level permissions are not yet available for this source. {link}" values={{ link: ( - + {LEARN_MORE_LINK} ), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx index a08f49b8bbe78..b62648348ed80 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx @@ -44,8 +44,13 @@ interface GithubViaAppProps { export const GitHubViaApp: React.FC = ({ isGithubEnterpriseServer }) => { const { isOrganization } = useValues(AppLogic); - const { githubAppId, githubEnterpriseServerUrl, isSubmitButtonLoading, indexPermissionsValue } = - useValues(GithubViaAppLogic); + const { + githubAppId, + githubEnterpriseServerUrl, + stagedPrivateKey, + isSubmitButtonLoading, + indexPermissionsValue, + } = useValues(GithubViaAppLogic); const { setGithubAppId, setGithubEnterpriseServerUrl, @@ -118,7 +123,12 @@ export const GitHubViaApp: React.FC = ({ isGithubEnterpriseSe fill type="submit" isLoading={isSubmitButtonLoading} - isDisabled={!githubAppId || (isGithubEnterpriseServer && !githubEnterpriseServerUrl)} + isDisabled={ + // disable submit button if any required fields are empty + !githubAppId || + (isGithubEnterpriseServer && !githubEnterpriseServerUrl) || + !stagedPrivateKey + } > {isSubmitButtonLoading ? 'Connecting…' : `Connect ${name}`} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx index bbf1b66277c70..9dbbcc537fa31 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx @@ -24,14 +24,13 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { docLinks } from '../../../../../shared/doc_links'; import { LicensingLogic } from '../../../../../shared/licensing'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { LicenseBadge } from '../../../../components/shared/license_badge'; import { SOURCES_PATH, SOURCE_DISPLAY_SETTINGS_PATH, - CUSTOM_API_DOCUMENT_PERMISSIONS_DOCS_URL, - ENT_SEARCH_LICENSE_MANAGEMENT, getContentSourcePath, getSourcesPath, } from '../../../../routes'; @@ -178,7 +177,10 @@ export const SaveCustom: React.FC = ({ defaultMessage="{link} manage content access content on individual or group attributes. Allow or deny access to specific documents." values={{ link: ( - + {SAVE_CUSTOM_DOC_PERMISSIONS_LINK} ), @@ -189,7 +191,7 @@ export const SaveCustom: React.FC = ({ {!hasPlatinumLicense && ( - + {LEARN_CUSTOM_FEATURES_BUTTON} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/download_diagnostics_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/download_diagnostics_button.test.tsx new file mode 100644 index 0000000000000..ca2af637c1d6e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/download_diagnostics_button.test.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 { setMockValues } from '../../../../__mocks__/kea_logic'; +import { fullContentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton } from '@elastic/eui'; + +import { DownloadDiagnosticsButton } from './download_diagnostics_button'; + +describe('DownloadDiagnosticsButton', () => { + const label = 'foo123'; + const contentSource = fullContentSources[0]; + const buttonLoading = false; + const isOrganization = true; + + const mockValues = { + contentSource, + buttonLoading, + isOrganization, + }; + + beforeEach(() => { + setMockValues(mockValues); + }); + + it('renders the Download diagnostics button with org href', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButton).prop('href')).toEqual( + '/internal/workplace_search/org/sources/123/download_diagnostics' + ); + }); + + it('renders the Download diagnostics button with account href', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.find(EuiButton).prop('href')).toEqual( + '/internal/workplace_search/account/sources/123/download_diagnostics' + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/download_diagnostics_button.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/download_diagnostics_button.tsx new file mode 100644 index 0000000000000..866746f43d653 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/download_diagnostics_button.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 { EuiButton } from '@elastic/eui'; + +import { HttpLogic } from '../../../../shared/http'; +import { AppLogic } from '../../../app_logic'; + +import { SourceLogic } from '../source_logic'; + +interface Props { + label: string; +} + +export const DownloadDiagnosticsButton: React.FC = ({ label }) => { + const { http } = useValues(HttpLogic); + const { isOrganization } = useValues(AppLogic); + const { + contentSource: { id, serviceType }, + buttonLoading, + } = useValues(SourceLogic); + + const diagnosticsPath = isOrganization + ? http.basePath.prepend(`/internal/workplace_search/org/sources/${id}/download_diagnostics`) + : http.basePath.prepend( + `/internal/workplace_search/account/sources/${id}/download_diagnostics` + ); + + return ( + + {label} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 29abbf94db397..d3714c2174b66 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -33,6 +33,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { CANCEL_BUTTON_LABEL, START_BUTTON_LABEL } from '../../../../shared/constants'; +import { docLinks } from '../../../../shared/doc_links'; import { EuiListGroupItemTo, EuiLinkTo } from '../../../../shared/react_router_helpers'; import { AppLogic } from '../../../app_logic'; import aclImage from '../../../assets/supports_acl.svg'; @@ -46,10 +47,6 @@ import { DOCUMENTATION_LINK_TITLE, } from '../../../constants'; import { - CUSTOM_SOURCE_DOCS_URL, - DOCUMENT_PERMISSIONS_DOCS_URL, - ENT_SEARCH_LICENSE_MANAGEMENT, - EXTERNAL_IDENTITIES_DOCS_URL, SYNC_FREQUENCY_PATH, BLOCKED_TIME_WINDOWS_PATH, getGroupPath, @@ -347,7 +344,7 @@ export const Overview: React.FC = () => { defaultMessage="{learnMoreLink} about permissions" values={{ learnMoreLink: ( - + {LEARN_MORE_LINK} ), @@ -408,7 +405,7 @@ export const Overview: React.FC = () => { defaultMessage="The {externalIdentitiesLink} must be used to configure user access mappings. Read the guide to learn more." values={{ externalIdentitiesLink: ( - + {EXTERNAL_IDENTITIES_LINK} ), @@ -466,7 +463,7 @@ export const Overview: React.FC = () => { - + {LEARN_CUSTOM_FEATURES_BUTTON} @@ -569,7 +566,7 @@ export const Overview: React.FC = () => { defaultMessage="{learnMoreLink} about custom sources." values={{ learnMoreLink: ( - + {LEARN_MORE_LINK} ), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx index 6b0e43fbce0c4..e37849033a144 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx @@ -31,12 +31,12 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { docLinks } from '../../../../shared/doc_links'; import { TruncatedContent } from '../../../../shared/truncate'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; -import { CUSTOM_SOURCE_DOCS_URL } from '../../../routes'; import { SourceContentItem } from '../../../types'; import { NO_CONTENT_MESSAGE, @@ -110,7 +110,7 @@ export const SourceContent: React.FC = () => { defaultMessage="Learn more about adding content in our {documentationLink}" values={{ documentationLink: ( - + {CUSTOM_DOCUMENTATION_LINK} ), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx index 944a54169f0b8..62d1bff27dd78 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx @@ -18,6 +18,7 @@ import { EuiCallOut } from '@elastic/eui'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../../components/layout'; +import { DownloadDiagnosticsButton } from './download_diagnostics_button'; import { SourceInfoCard } from './source_info_card'; import { SourceLayout } from './source_layout'; @@ -26,6 +27,7 @@ describe('SourceLayout', () => { const mockValues = { contentSource, dataLoading: false, + diagnosticDownloadButtonVisible: false, isOrganization: true, }; @@ -87,4 +89,14 @@ describe('SourceLayout', () => { expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); + + it('renders DownloadDiagnosticsButton', () => { + setMockValues({ + ...mockValues, + diagnosticDownloadButtonVisible: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(DownloadDiagnosticsButton)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx index 663088f797c18..727e171d1073c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx @@ -12,19 +12,21 @@ import moment from 'moment'; import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import { docLinks } from '../../../../shared/doc_links'; import { PageTemplateProps } from '../../../../shared/layout'; import { AppLogic } from '../../../app_logic'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../../components/layout'; import { NAV } from '../../../constants'; -import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; import { + DOWNLOAD_DIAGNOSTIC_BUTTON, SOURCE_DISABLED_CALLOUT_TITLE, SOURCE_DISABLED_CALLOUT_DESCRIPTION, SOURCE_DISABLED_CALLOUT_BUTTON, } from '../constants'; import { SourceLogic } from '../source_logic'; +import { DownloadDiagnosticsButton } from './download_diagnostics_button'; import { SourceInfoCard } from './source_info_card'; export const SourceLayout: React.FC = ({ @@ -32,7 +34,7 @@ export const SourceLayout: React.FC = ({ pageChrome = [], ...props }) => { - const { contentSource, dataLoading } = useValues(SourceLogic); + const { contentSource, dataLoading, diagnosticDownloadButtonVisible } = useValues(SourceLogic); const { isOrganization } = useValues(AppLogic); const { name, createdAt, serviceType, isFederatedSource, supportedByLicense } = contentSource; @@ -53,7 +55,7 @@ export const SourceLayout: React.FC = ({ <>

{SOURCE_DISABLED_CALLOUT_DESCRIPTION}

- + {SOURCE_DISABLED_CALLOUT_BUTTON}
@@ -61,6 +63,13 @@ export const SourceLayout: React.FC = ({ ); + const downloadDiagnosticButton = ( + <> + + + + ); + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( @@ -69,6 +78,7 @@ export const SourceLayout: React.FC = ({ {...props} pageChrome={[NAV.SOURCES, name || '...', ...pageChrome]} > + {diagnosticDownloadButtonVisible && downloadDiagnosticButton} {!supportedByLicense && callout} {pageHeader} {children} 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 83cf21ce86233..ec499293f2fd1 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 @@ -18,6 +18,7 @@ import { EuiConfirmModal } from '@elastic/eui'; import { SourceConfigFields } from '../../../components/shared/source_config_fields'; +import { DownloadDiagnosticsButton } from './download_diagnostics_button'; import { SourceSettings } from './source_settings'; describe('SourceSettings', () => { @@ -48,6 +49,7 @@ describe('SourceSettings', () => { const wrapper = shallow(); expect(wrapper.find('form')).toHaveLength(1); + expect(wrapper.find(DownloadDiagnosticsButton)).toHaveLength(1); }); it('handles form submission', () => { @@ -104,36 +106,4 @@ describe('SourceSettings', () => { sourceConfigData.configuredFields.publicKey ); }); - - describe('DownloadDiagnosticsButton', () => { - it('renders for org with correct href', () => { - const wrapper = shallow(); - - expect(wrapper.find('[data-test-subj="DownloadDiagnosticsButton"]').prop('href')).toEqual( - '/internal/workplace_search/org/sources/123/download_diagnostics' - ); - }); - - it('renders for account with correct href', () => { - setMockValues({ - ...mockValues, - isOrganization: false, - }); - const wrapper = shallow(); - - expect(wrapper.find('[data-test-subj="DownloadDiagnosticsButton"]').prop('href')).toEqual( - '/internal/workplace_search/account/sources/123/download_diagnostics' - ); - }); - - it('renders with the correct download file name', () => { - jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('1970-01-01').valueOf()); - - const wrapper = shallow(); - - expect(wrapper.find('[data-test-subj="DownloadDiagnosticsButton"]').prop('download')).toEqual( - '123_custom_0_diagnostics.json' - ); - }); - }); }); 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 e5924b672c771..484a9ca14b4e1 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 @@ -23,7 +23,6 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { HttpLogic } from '../../../../shared/http'; import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers'; import { AppLogic } from '../../../app_logic'; import { ContentSection } from '../../../components/shared/content_section'; @@ -61,11 +60,11 @@ import { import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; +import { DownloadDiagnosticsButton } from './download_diagnostics_button'; + import { SourceLayout } from './source_layout'; export const SourceSettings: React.FC = () => { - const { http } = useValues(HttpLogic); - const { updateContentSource, removeContentSource, @@ -110,12 +109,6 @@ export const SourceSettings: React.FC = () => { const { clientId, clientSecret, publicKey, consumerKey, baseUrl } = configuredFields || {}; - const diagnosticsPath = isOrganization - ? http.basePath.prepend(`/internal/workplace_search/org/sources/${id}/download_diagnostics`) - : http.basePath.prepend( - `/internal/workplace_search/account/sources/${id}/download_diagnostics` - ); - const handleNameChange = (e: ChangeEvent) => setValue(e.target.value); const submitNameChange = (e: FormEvent) => { @@ -241,15 +234,7 @@ export const SourceSettings: React.FC = () => { )} - - {SYNC_DIAGNOSTICS_BUTTON} - + = ({ tabId }) => { description={ <> {SOURCE_FREQUENCY_DESCRIPTION}{' '} - + {LEARN_MORE_LINK} 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 2dfa2a6420f7f..460f7e7f42055 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 @@ -22,10 +22,10 @@ import { } from '@elastic/eui'; import { SAVE_BUTTON_LABEL } from '../../../../../shared/constants'; +import { docLinks } from '../../../../../shared/doc_links'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; import { NAV, RESET_BUTTON } from '../../../../constants'; -import { OBJECTS_AND_ASSETS_DOCS_URL } from '../../../../routes'; import { LEARN_MORE_LINK, SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL, @@ -87,7 +87,7 @@ export const ObjectsAndAssets: React.FC = () => { description={ <> {SOURCE_OBJECTS_AND_ASSETS_DESCRIPTION}{' '} - + {LEARN_MORE_LINK} 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 dec275adb3c50..2e777fa906dd6 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 @@ -11,9 +11,9 @@ import { useActions, useValues } from 'kea'; import { EuiCallOut, EuiLink, EuiPanel, EuiSwitch, EuiSpacer, EuiText } from '@elastic/eui'; +import { docLinks } from '../../../../../shared/doc_links'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; import { NAV } from '../../../../constants'; -import { SYNCHRONIZATION_DOCS_URL } from '../../../../routes'; import { LEARN_MORE_LINK, SOURCE_SYNCHRONIZATION_DESCRIPTION, @@ -68,7 +68,7 @@ export const Synchronization: React.FC = () => { description={ <> {SOURCE_SYNCHRONIZATION_DESCRIPTION}{' '} - + {LEARN_MORE_LINK} 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 61e4aa3fc3884..43b391bc1d824 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 @@ -370,6 +370,13 @@ export const SYNC_DIAGNOSTICS_BUTTON = i18n.translate( } ); +export const DOWNLOAD_DIAGNOSTIC_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.downloadDiagnosticButton', + { + defaultMessage: 'Download diagnostic bundle', + } +); + export const SOURCE_NAME_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.sourceName.label', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 687461296ac9e..20a0673709b5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -7,6 +7,8 @@ import { i18n } from '@kbn/i18n'; +import { docLinks } from '../../../shared/doc_links'; + import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; import { ADD_BOX_PATH, @@ -45,23 +47,6 @@ import { EDIT_SLACK_PATH, EDIT_ZENDESK_PATH, EDIT_CUSTOM_PATH, - BOX_DOCS_URL, - CONFLUENCE_DOCS_URL, - CONFLUENCE_SERVER_DOCS_URL, - GITHUB_ENTERPRISE_DOCS_URL, - DROPBOX_DOCS_URL, - GITHUB_DOCS_URL, - GMAIL_DOCS_URL, - GOOGLE_DRIVE_DOCS_URL, - JIRA_DOCS_URL, - JIRA_SERVER_DOCS_URL, - ONEDRIVE_DOCS_URL, - SALESFORCE_DOCS_URL, - SERVICENOW_DOCS_URL, - SHAREPOINT_DOCS_URL, - SLACK_DOCS_URL, - ZENDESK_DOCS_URL, - CUSTOM_SOURCE_DOCS_URL, } from '../../routes'; import { FeatureIds, SourceDataItem } from '../../types'; @@ -75,7 +60,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: BOX_DOCS_URL, + documentationUrl: docLinks.workplaceSearchBox, applicationPortalUrl: 'https://app.box.com/developers/console', }, objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], @@ -104,7 +89,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: true, - documentationUrl: CONFLUENCE_DOCS_URL, + documentationUrl: docLinks.workplaceSearchConfluenceCloud, applicationPortalUrl: 'https://developer.atlassian.com/console/myapps/', }, objTypes: [ @@ -138,7 +123,7 @@ export const staticSourceData = [ isPublicKey: true, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: CONFLUENCE_SERVER_DOCS_URL, + documentationUrl: docLinks.workplaceSearchConfluenceServer, }, objTypes: [ SOURCE_OBJ_TYPES.PAGES, @@ -170,7 +155,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: DROPBOX_DOCS_URL, + documentationUrl: docLinks.workplaceSearchDropbox, applicationPortalUrl: 'https://www.dropbox.com/developers/apps', }, objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], @@ -200,7 +185,7 @@ export const staticSourceData = [ hasOauthRedirect: true, needsBaseUrl: false, needsConfiguration: true, - documentationUrl: GITHUB_DOCS_URL, + documentationUrl: docLinks.workplaceSearchGitHub, applicationPortalUrl: 'https://github.com/settings/developers', applicationLinkTitle: GITHUB_LINK_TITLE, }, @@ -242,7 +227,7 @@ export const staticSourceData = [ defaultMessage: 'GitHub Enterprise URL', } ), - documentationUrl: GITHUB_ENTERPRISE_DOCS_URL, + documentationUrl: docLinks.workplaceSearchGitHub, applicationPortalUrl: 'https://github.com/settings/developers', applicationLinkTitle: GITHUB_LINK_TITLE, }, @@ -277,7 +262,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: GMAIL_DOCS_URL, + documentationUrl: docLinks.workplaceSearchGmail, applicationPortalUrl: 'https://console.developers.google.com/', }, objTypes: [SOURCE_OBJ_TYPES.EMAILS], @@ -295,7 +280,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: GOOGLE_DRIVE_DOCS_URL, + documentationUrl: docLinks.workplaceSearchGoogleDrive, applicationPortalUrl: 'https://console.developers.google.com/', }, objTypes: [ @@ -328,7 +313,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: true, - documentationUrl: JIRA_DOCS_URL, + documentationUrl: docLinks.workplaceSearchJiraCloud, applicationPortalUrl: 'https://developer.atlassian.com/console/myapps/', }, objTypes: [ @@ -364,7 +349,7 @@ export const staticSourceData = [ isPublicKey: true, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: JIRA_SERVER_DOCS_URL, + documentationUrl: docLinks.workplaceSearchJiraServer, applicationPortalUrl: '', }, objTypes: [ @@ -399,7 +384,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: ONEDRIVE_DOCS_URL, + documentationUrl: docLinks.workplaceSearchOneDrive, applicationPortalUrl: 'https://portal.azure.com/', }, objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], @@ -428,7 +413,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: SALESFORCE_DOCS_URL, + documentationUrl: docLinks.workplaceSearchSalesforce, applicationPortalUrl: 'https://salesforce.com/', }, objTypes: [ @@ -464,7 +449,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: SALESFORCE_DOCS_URL, + documentationUrl: docLinks.workplaceSearchSalesforce, applicationPortalUrl: 'https://test.salesforce.com/', }, objTypes: [ @@ -500,7 +485,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: true, - documentationUrl: SERVICENOW_DOCS_URL, + documentationUrl: docLinks.workplaceSearchServiceNow, applicationPortalUrl: 'https://www.servicenow.com/my-account/sign-in.html', }, objTypes: [ @@ -533,7 +518,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: SHAREPOINT_DOCS_URL, + documentationUrl: docLinks.workplaceSearchSharePoint, applicationPortalUrl: 'https://portal.azure.com/', }, objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], @@ -562,7 +547,7 @@ export const staticSourceData = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: false, - documentationUrl: SLACK_DOCS_URL, + documentationUrl: docLinks.workplaceSearchSlack, applicationPortalUrl: 'https://api.slack.com/apps/', }, objTypes: [ @@ -585,7 +570,7 @@ export const staticSourceData = [ hasOauthRedirect: true, needsBaseUrl: false, needsSubdomain: true, - documentationUrl: ZENDESK_DOCS_URL, + documentationUrl: docLinks.workplaceSearchZendesk, applicationPortalUrl: 'https://www.zendesk.com/login/', }, objTypes: [SOURCE_OBJ_TYPES.TICKETS], @@ -617,7 +602,7 @@ export const staticSourceData = [ defaultMessage: 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', }), - documentationUrl: CUSTOM_SOURCE_DOCS_URL, + documentationUrl: docLinks.workplaceSearchCustomSources, applicationPortalUrl: '', }, accountContextOnly: false, 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 e7888175bb31a..420909df081b3 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 @@ -40,6 +40,7 @@ describe('SourceLogic', () => { dataLoading: true, sectionLoading: true, buttonLoading: false, + diagnosticDownloadButtonVisible: false, contentMeta: DEFAULT_META, contentFilterValue: '', isConfigurationUpdateButtonLoading: false, @@ -125,6 +126,12 @@ describe('SourceLogic', () => { expect(SourceLogic.values.buttonLoading).toEqual(false); }); + + it('showDiagnosticDownloadButton', () => { + SourceLogic.actions.showDiagnosticDownloadButton(); + + expect(SourceLogic.values.diagnosticDownloadButtonVisible).toEqual(true); + }); }); describe('listeners', () => { @@ -183,6 +190,27 @@ describe('SourceLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('error'); }); + it('handles error message with diagnostic bundle error message', async () => { + const showDiagnosticDownloadButtonSpy = jest.spyOn( + SourceLogic.actions, + 'showDiagnosticDownloadButton' + ); + + // For contenst source errors, the API returns the source errors in an error property in the success + // response. We don't reject here because we still render the content source with the error. + const promise = Promise.resolve({ + ...contentSource, + errors: [ + 'The database is on fire. [Check diagnostic bundle for details - Message id: 123]', + ], + }); + http.get.mockReturnValue(promise); + SourceLogic.actions.initializeSource(contentSource.id); + await promise; + + expect(showDiagnosticDownloadButtonSpy).toHaveBeenCalled(); + }); + describe('404s', () => { const mock404 = Promise.reject({ response: { status: 404 } }); 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 b76627f57b3a3..8f0cfa8cfa280 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 @@ -52,6 +52,7 @@ export interface SourceActions { setButtonNotLoading(): void; setStagedPrivateKey(stagedPrivateKey: string | null): string | null; setConfigurationUpdateButtonNotLoading(): void; + showDiagnosticDownloadButton(): void; } interface SourceValues { @@ -59,6 +60,7 @@ interface SourceValues { dataLoading: boolean; sectionLoading: boolean; buttonLoading: boolean; + diagnosticDownloadButtonVisible: boolean; contentItems: SourceContentItem[]; contentMeta: Meta; contentFilterValue: string; @@ -108,6 +110,7 @@ export const SourceLogic = kea>({ setButtonNotLoading: () => false, setStagedPrivateKey: (stagedPrivateKey: string) => stagedPrivateKey, setConfigurationUpdateButtonNotLoading: () => false, + showDiagnosticDownloadButton: true, }, reducers: { contentSource: [ @@ -147,6 +150,13 @@ export const SourceLogic = kea>({ setSearchResults: () => false, }, ], + diagnosticDownloadButtonVisible: [ + false, + { + showDiagnosticDownloadButton: () => true, + initializeSource: () => false, + }, + ], contentItems: [ [], { @@ -200,6 +210,9 @@ export const SourceLogic = kea>({ } if (response.errors) { setErrorMessage(response.errors); + if (errorsHaveDiagnosticBundleString(response.errors as unknown as string[])) { + actions.showDiagnosticDownloadButton(); + } } else { clearFlashMessages(); } @@ -343,3 +356,8 @@ const setPage = (state: Meta, page: number) => ({ current: page, }, }); + +const errorsHaveDiagnosticBundleString = (errors: string[]) => { + const ERROR_SUBSTRING = 'Check diagnostic bundle for details'; + return errors.find((e) => e.includes(ERROR_SUBSTRING)); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx index 8697f10f8afaf..a7c981dad9103 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx @@ -24,9 +24,9 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { docLinks } from '../../../shared/doc_links'; import { Loading } from '../../../shared/loading'; import { SourceIcon } from '../../components/shared/source_icon'; -import { EXTERNAL_IDENTITIES_DOCS_URL, DOCUMENT_PERMISSIONS_DOCS_URL } from '../../routes'; import { EXTERNAL_IDENTITIES_LINK, @@ -82,7 +82,7 @@ export const SourcesView: React.FC = ({ children }) => { values={{ addedSourceName, externalIdentitiesLink: ( - + {EXTERNAL_IDENTITIES_LINK} ), @@ -96,7 +96,7 @@ export const SourcesView: React.FC = ({ children }) => { defaultMessage="Documents will not be searchable from Workplace Search until user and group mappings have been configured. {documentPermissionsLink}." values={{ documentPermissionsLink: ( - + {DOCUMENT_PERMISSIONS_LINK} ), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.test.tsx index a8fcdfd7cb257..e4e14b19f1894 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.test.tsx @@ -15,8 +15,10 @@ import { ErrorState } from './'; describe('ErrorState', () => { it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + const prompt = wrapper.find(ErrorStatePrompt); + expect(prompt).toHaveLength(1); + expect(prompt.prop('errorConnectingMessage')).toEqual('I am an error'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx index 83ac3a26c44e5..493c37189ceb7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx @@ -15,7 +15,9 @@ import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kiban import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { ViewContentHeader } from '../../components/shared/view_content_header'; -export const ErrorState: React.FC = () => { +export const ErrorState: React.FC<{ errorConnectingMessage?: string }> = ({ + errorConnectingMessage, +}) => { return ( <> @@ -23,7 +25,7 @@ export const ErrorState: React.FC = () => { - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index f7e578b1b4d23..c0362b44b618b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -12,6 +12,7 @@ import { useActions, useValues } from 'kea'; import { EuiSpacer } from '@elastic/eui'; import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { docLinks } from '../../../shared/doc_links'; import { RoleMappingsTable, RoleMappingsHeading, @@ -22,7 +23,6 @@ import { } from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { WorkplaceSearchPageTemplate } from '../../components/layout'; -import { SECURITY_DOCS_URL } from '../../routes'; import { ROLE_MAPPINGS_TABLE_HEADER } from './constants'; @@ -56,7 +56,7 @@ export const RoleMappings: React.FC = () => { const rolesEmptyState = ( ); @@ -65,7 +65,7 @@ export const RoleMappings: React.FC = () => {
initializeRoleMapping()} /> { @@ -100,7 +100,7 @@ export const OauthApplication: React.FC = () => { <> {NON_PLATINUM_OAUTH_DESCRIPTION} - + {EXPLORE_PLATINUM_FEATURES_LINK} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx index 009dbffafebd8..3c3a7085d7116 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx @@ -12,14 +12,14 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { docLinks } from '../../../shared/doc_links'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SetupGuideLayout, SETUP_GUIDE_TITLE } from '../../../shared/setup_guide'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { GETTING_STARTED_DOCS_URL } from '../../routes'; import GettingStarted from './assets/getting_started.png'; -const GETTING_STARTED_LINK_URL = GETTING_STARTED_DOCS_URL; +const GETTING_STARTED_LINK_URL = docLinks.workplaceSearchGettingStarted; export const SetupGuide: React.FC = () => { return ( diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 19f2aa212d7fd..9a8ff64649f0e 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -39,6 +39,7 @@ export interface ClientConfigType { export interface ClientData extends InitialAppData { publicUrl?: string; errorConnecting?: boolean; + errorConnectingMessage?: string; } interface PluginsSetup { @@ -193,8 +194,9 @@ export class EnterpriseSearchPlugin implements Plugin { try { this.data = await http.get('/internal/enterprise_search/config_data'); this.hasInitialized = true; - } catch { + } catch (e) { this.data.errorConnecting = true; + this.data.errorConnectingMessage = `${e.res.status} ${e.message}`; } } } diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index f6e3280a8abb2..0a0a097da10aa 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -120,6 +120,7 @@ describe('callEnterpriseSearchConfigAPI', () => { expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({ ...DEFAULT_INITIAL_APP_DATA, + errorConnectingMessage: undefined, kibanaVersion: '1.0.0', access: { hasAppSearchAccess: true, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts index 3070be1e56b5b..c9212bca322d7 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts @@ -28,7 +28,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler', + path: '/api/as/v1/engines/:name/crawler', }); }); @@ -61,7 +61,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/crawl_requests', + path: '/api/as/v1/engines/:name/crawler/crawl_requests', }); }); @@ -94,7 +94,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/crawl_requests/:id', + path: '/api/as/v1/engines/:name/crawler/crawl_requests/:id', }); }); @@ -132,7 +132,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/crawl_requests', + path: '/api/as/v1/engines/:name/crawler/crawl_requests', }); }); @@ -165,7 +165,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/domains', + path: '/api/as/v1/engines/:name/crawler/domains', }); }); @@ -204,7 +204,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/crawl_requests/active/cancel', + path: '/api/as/v1/engines/:name/crawler/crawl_requests/active/cancel', }); }); @@ -237,7 +237,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/domains', + path: '/api/as/v1/engines/:name/crawler/domains', }); }); @@ -293,7 +293,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/domains/:id', + path: '/api/as/v1/engines/:name/crawler/domains/:id', }); }); @@ -339,7 +339,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/domains/:id', + path: '/api/as/v1/engines/:name/crawler/domains/:id', }); }); @@ -397,7 +397,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/domains/:id', + path: '/api/as/v1/engines/:name/crawler/domains/:id', }); }); @@ -435,7 +435,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/crawler/validate_url', + path: '/api/as/v1/crawler/validate_url', }); }); @@ -472,7 +472,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/process_crawls', + path: '/api/as/v1/engines/:name/crawler/process_crawls', }); }); @@ -519,7 +519,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/crawl_schedule', + path: '/api/as/v1/engines/:name/crawler/crawl_schedule', }); }); @@ -556,7 +556,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/crawl_schedule', + path: '/api/as/v1/engines/:name/crawler/crawl_schedule', }); }); @@ -611,7 +611,7 @@ describe('crawler routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:name/crawler/crawl_schedule', + path: '/api/as/v1/engines/:name/crawler/crawl_schedule', }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts index f53b15dadd061..f0fdc5c16098b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -23,7 +23,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler', + path: '/api/as/v1/engines/:name/crawler', }) ); @@ -37,7 +37,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/crawl_requests', + path: '/api/as/v1/engines/:name/crawler/crawl_requests', }) ); @@ -52,7 +52,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/crawl_requests/:id', + path: '/api/as/v1/engines/:name/crawler/crawl_requests/:id', }) ); @@ -66,7 +66,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/crawl_requests', + path: '/api/as/v1/engines/:name/crawler/crawl_requests', }) ); @@ -80,7 +80,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/crawl_requests/active/cancel', + path: '/api/as/v1/engines/:name/crawler/crawl_requests/active/cancel', }) ); @@ -98,7 +98,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/domains', + path: '/api/as/v1/engines/:name/crawler/domains', }) ); @@ -123,7 +123,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/domains', + path: '/api/as/v1/engines/:name/crawler/domains', }) ); @@ -138,7 +138,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/domains/:id', + path: '/api/as/v1/engines/:name/crawler/domains/:id', }) ); @@ -156,7 +156,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/domains/:id', + path: '/api/as/v1/engines/:name/crawler/domains/:id', }) ); @@ -183,7 +183,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/domains/:id', + path: '/api/as/v1/engines/:name/crawler/domains/:id', }) ); @@ -198,7 +198,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/crawler/validate_url', + path: '/api/as/v1/crawler/validate_url', }) ); @@ -215,7 +215,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/process_crawls', + path: '/api/as/v1/engines/:name/crawler/process_crawls', }) ); @@ -229,7 +229,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/crawl_schedule', + path: '/api/as/v1/engines/:name/crawler/crawl_schedule', }) ); @@ -247,7 +247,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/crawl_schedule', + path: '/api/as/v1/engines/:name/crawler/crawl_schedule', }) ); @@ -261,7 +261,7 @@ export function registerCrawlerRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:name/crawler/crawl_schedule', + path: '/api/as/v1/engines/:name/crawler/crawl_schedule', }) ); } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.test.ts index 018ab433536b2..c3d1468687ec4 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.test.ts @@ -28,7 +28,7 @@ describe('crawler crawl rules routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/crawl_rules', params: { respond_with: 'index', }, @@ -71,7 +71,7 @@ describe('crawler crawl rules routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', params: { respond_with: 'index', }, @@ -115,7 +115,7 @@ describe('crawler crawl rules routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', params: { respond_with: 'index', }, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.ts index 7c82c73db7263..26637623f0885 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_crawl_rules.ts @@ -29,7 +29,7 @@ export function registerCrawlerCrawlRulesRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/crawl_rules', params: { respond_with: 'index', }, @@ -54,7 +54,7 @@ export function registerCrawlerCrawlRulesRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', params: { respond_with: 'index', }, @@ -73,7 +73,7 @@ export function registerCrawlerCrawlRulesRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/crawl_rules/:crawlRuleId', params: { respond_with: 'index', }, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_entry_points.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_entry_points.test.ts index 6fb7e99400877..dc7ad493a5149 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_entry_points.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_entry_points.test.ts @@ -28,7 +28,7 @@ describe('crawler entry point routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/entry_points', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/entry_points', params: { respond_with: 'index', }, @@ -69,7 +69,7 @@ describe('crawler entry point routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/entry_points/:entryPointId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/entry_points/:entryPointId', params: { respond_with: 'index', }, @@ -110,7 +110,7 @@ describe('crawler entry point routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/entry_points/:entryPointId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/entry_points/:entryPointId', params: { respond_with: 'index', }, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_entry_points.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_entry_points.ts index a6d6fdb24b41f..fd81475c860ad 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_entry_points.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_entry_points.ts @@ -27,7 +27,7 @@ export function registerCrawlerEntryPointRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/entry_points', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/entry_points', params: { respond_with: 'index', }, @@ -49,7 +49,7 @@ export function registerCrawlerEntryPointRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/entry_points/:entryPointId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/entry_points/:entryPointId', params: { respond_with: 'index', }, @@ -68,7 +68,7 @@ export function registerCrawlerEntryPointRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/entry_points/:entryPointId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/entry_points/:entryPointId', params: { respond_with: 'index', }, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.test.ts index a37a8311093c7..3d6eb86bcba26 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.test.ts @@ -28,7 +28,7 @@ describe('crawler sitemap routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/sitemaps', params: { respond_with: 'index', }, @@ -69,7 +69,7 @@ describe('crawler sitemap routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', params: { respond_with: 'index', }, @@ -110,7 +110,7 @@ describe('crawler sitemap routes', () => { it('creates a request to enterprise search', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', params: { respond_with: 'index', }, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.ts index b63473888eecc..0965acd967306 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler_sitemaps.ts @@ -27,7 +27,7 @@ export function registerCrawlerSitemapRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/sitemaps', params: { respond_with: 'index', }, @@ -49,7 +49,7 @@ export function registerCrawlerSitemapRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', params: { respond_with: 'index', }, @@ -68,7 +68,7 @@ export function registerCrawlerSitemapRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', + path: '/api/as/v1/engines/:engineName/crawler/domains/:domainId/sitemaps/:sitemapId', params: { respond_with: 'index', }, diff --git a/x-pack/plugins/fleet/common/constants/preconfiguration.ts b/x-pack/plugins/fleet/common/constants/preconfiguration.ts index 3e7377477c93e..7d22716bc0f1e 100644 --- a/x-pack/plugins/fleet/common/constants/preconfiguration.ts +++ b/x-pack/plugins/fleet/common/constants/preconfiguration.ts @@ -6,6 +6,7 @@ */ import { uniqBy } from 'lodash'; +import uuidv5 from 'uuid/v5'; import type { PreconfiguredAgentPolicy } from '../types'; @@ -18,6 +19,9 @@ import { autoUpgradePoliciesPackages, } from './epm'; +// UUID v5 values require a namespace. We use UUID v5 for some of our preconfigured ID values. +export const UUID_V5_NAMESPACE = 'dde7c2de-1370-4c19-9975-b473d0e03508'; + export const PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE = 'fleet-preconfiguration-deletion-record'; @@ -25,17 +29,22 @@ export const PRECONFIGURATION_LATEST_KEYWORD = 'latest'; type PreconfiguredAgentPolicyWithDefaultInputs = Omit< PreconfiguredAgentPolicy, - 'package_policies' | 'id' + 'package_policies' > & { package_policies: Array>; }; +export const DEFAULT_AGENT_POLICY_ID_SEED = 'default-agent-policy'; +export const DEFAULT_SYSTEM_PACKAGE_POLICY_ID = 'default-system-policy'; + export const DEFAULT_AGENT_POLICY: PreconfiguredAgentPolicyWithDefaultInputs = { + id: uuidv5(DEFAULT_AGENT_POLICY_ID_SEED, UUID_V5_NAMESPACE), name: 'Default policy', namespace: 'default', description: 'Default agent policy created by Kibana', package_policies: [ { + id: DEFAULT_SYSTEM_PACKAGE_POLICY_ID, name: `${FLEET_SYSTEM_PACKAGE}-1`, package: { name: FLEET_SYSTEM_PACKAGE, @@ -47,12 +56,17 @@ export const DEFAULT_AGENT_POLICY: PreconfiguredAgentPolicyWithDefaultInputs = { monitoring_enabled: monitoringTypes, }; +export const DEFAULT_FLEET_SERVER_POLICY_ID = 'default-fleet-server-agent-policy'; +export const DEFAULT_FLEET_SERVER_AGENT_POLICY_ID_SEED = 'default-fleet-server'; + export const DEFAULT_FLEET_SERVER_AGENT_POLICY: PreconfiguredAgentPolicyWithDefaultInputs = { + id: uuidv5(DEFAULT_FLEET_SERVER_AGENT_POLICY_ID_SEED, UUID_V5_NAMESPACE), name: 'Default Fleet Server policy', namespace: 'default', description: 'Default Fleet Server agent policy created by Kibana', package_policies: [ { + id: DEFAULT_FLEET_SERVER_POLICY_ID, name: `${FLEET_SERVER_PACKAGE}-1`, package: { name: FLEET_SERVER_PACKAGE, diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 9fc20bbf38eb7..69363f37d33e0 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -18,8 +18,8 @@ export const LIMITED_CONCURRENCY_ROUTE_TAG = 'ingest:limited-concurrency'; // EPM API routes const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`; const EPM_PACKAGES_BULK = `${EPM_PACKAGES_MANY}/_bulk`; -const EPM_PACKAGES_ONE = `${EPM_PACKAGES_MANY}/{pkgkey}`; -const EPM_PACKAGES_FILE = `${EPM_PACKAGES_MANY}/{pkgName}/{pkgVersion}`; +const EPM_PACKAGES_ONE_DEPRECATED = `${EPM_PACKAGES_MANY}/{pkgkey}`; +const EPM_PACKAGES_ONE = `${EPM_PACKAGES_MANY}/{pkgName}/{pkgVersion}`; export const EPM_API_ROUTES = { BULK_INSTALL_PATTERN: EPM_PACKAGES_BULK, LIST_PATTERN: EPM_PACKAGES_MANY, @@ -28,9 +28,13 @@ export const EPM_API_ROUTES = { INSTALL_FROM_REGISTRY_PATTERN: EPM_PACKAGES_ONE, INSTALL_BY_UPLOAD_PATTERN: EPM_PACKAGES_MANY, DELETE_PATTERN: EPM_PACKAGES_ONE, - FILEPATH_PATTERN: `${EPM_PACKAGES_FILE}/{filePath*}`, + FILEPATH_PATTERN: `${EPM_PACKAGES_ONE}/{filePath*}`, CATEGORIES_PATTERN: `${EPM_API_ROOT}/categories`, STATS_PATTERN: `${EPM_PACKAGES_MANY}/{pkgName}/stats`, + + INFO_PATTERN_DEPRECATED: EPM_PACKAGES_ONE_DEPRECATED, + INSTALL_FROM_REGISTRY_PATTERN_DEPRECATED: EPM_PACKAGES_ONE_DEPRECATED, + DELETE_PATTERN_DEPRECATED: EPM_PACKAGES_ONE_DEPRECATED, }; // Data stream API routes @@ -79,7 +83,9 @@ export const SETTINGS_API_ROUTES = { // App API routes export const APP_API_ROUTES = { CHECK_PERMISSIONS_PATTERN: `${API_ROOT}/check-permissions`, - GENERATE_SERVICE_TOKEN_PATTERN: `${API_ROOT}/service-tokens`, + GENERATE_SERVICE_TOKEN_PATTERN: `${API_ROOT}/service_tokens`, + // deprecated since 8.0 + GENERATE_SERVICE_TOKEN_PATTERN_DEPRECATED: `${API_ROOT}/service-tokens`, }; // Agent API routes @@ -95,16 +101,23 @@ export const AGENT_API_ROUTES = { BULK_UNENROLL_PATTERN: `${API_ROOT}/agents/bulk_unenroll`, REASSIGN_PATTERN: `${API_ROOT}/agents/{agentId}/reassign`, BULK_REASSIGN_PATTERN: `${API_ROOT}/agents/bulk_reassign`, - STATUS_PATTERN: `${API_ROOT}/agent-status`, + STATUS_PATTERN: `${API_ROOT}/agent_status`, + // deprecated since 8.0 + STATUS_PATTERN_DEPRECATED: `${API_ROOT}/agent-status`, UPGRADE_PATTERN: `${API_ROOT}/agents/{agentId}/upgrade`, BULK_UPGRADE_PATTERN: `${API_ROOT}/agents/bulk_upgrade`, }; export const ENROLLMENT_API_KEY_ROUTES = { - CREATE_PATTERN: `${API_ROOT}/enrollment-api-keys`, - LIST_PATTERN: `${API_ROOT}/enrollment-api-keys`, - INFO_PATTERN: `${API_ROOT}/enrollment-api-keys/{keyId}`, - DELETE_PATTERN: `${API_ROOT}/enrollment-api-keys/{keyId}`, + CREATE_PATTERN: `${API_ROOT}/enrollment_api_keys`, + LIST_PATTERN: `${API_ROOT}/enrollment_api_keys`, + INFO_PATTERN: `${API_ROOT}/enrollment_api_keys/{keyId}`, + DELETE_PATTERN: `${API_ROOT}/enrollment_api_keys/{keyId}`, + // deprecated since 8.0 + CREATE_PATTERN_DEPRECATED: `${API_ROOT}/enrollment-api-keys`, + LIST_PATTERN_DEPRECATED: `${API_ROOT}/enrollment-api-keys`, + INFO_PATTERN_DEPRECATED: `${API_ROOT}/enrollment-api-keys/{keyId}`, + DELETE_PATTERN_DEPRECATED: `${API_ROOT}/enrollment-api-keys/{keyId}`, }; // Agents setup API routes diff --git a/x-pack/plugins/fleet/common/constants/settings.ts b/x-pack/plugins/fleet/common/constants/settings.ts index 772d938086938..423e71edf10e6 100644 --- a/x-pack/plugins/fleet/common/constants/settings.ts +++ b/x-pack/plugins/fleet/common/constants/settings.ts @@ -6,3 +6,5 @@ */ export const GLOBAL_SETTINGS_SAVED_OBJECT_TYPE = 'ingest_manager_settings'; + +export const GLOBAL_SETTINGS_ID = 'fleet-default-settings'; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index f30369b5792b8..7423a4dc54bbe 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -157,6 +157,7 @@ "parameters": [] }, "/epm/packages/{pkgkey}": { + "deprecated": true, "get": { "summary": "Packages - Info", "tags": [], @@ -352,6 +353,210 @@ } } }, + "/epm/packages/{pkgName}/{pkgVersion}": { + "get": { + "summary": "Packages - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "allOf": [ + { + "properties": { + "item": { + "$ref": "#/components/schemas/package_info" + } + } + }, + { + "properties": { + "status": { + "type": "string", + "enum": [ + "installed", + "installing", + "install_failed", + "not_installed" + ] + }, + "savedObject": { + "type": "string" + } + }, + "required": [ + "status", + "savedObject" + ] + } + ] + } + } + } + } + }, + "operationId": "get-package", + "security": [ + { + "basicAuth": [] + } + ] + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "pkgName", + "in": "path", + "required": true + }, + { + "schema": { + "type": "string" + }, + "name": "pkgVersion", + "in": "path", + "required": true + } + ], + "post": { + "summary": "Packages - Install", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "oneOf": [ + { + "$ref": "#/components/schemas/kibana_saved_object_type" + }, + { + "$ref": "#/components/schemas/elasticsearch_asset_type" + } + ] + } + }, + "required": [ + "id", + "type" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + } + }, + "operationId": "install-package", + "description": "", + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { + "type": "boolean" + } + } + } + } + } + } + }, + "delete": { + "summary": "Packages - Delete", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "oneOf": [ + { + "$ref": "#/components/schemas/kibana_saved_object_type" + }, + { + "$ref": "#/components/schemas/elasticsearch_asset_type" + } + ] + } + }, + "required": [ + "id", + "type" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + } + }, + "operationId": "delete-package", + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { + "type": "boolean" + } + } + } + } + } + } + } + }, "/agents/setup": { "get": { "summary": "Agents setup - Info", @@ -419,6 +624,72 @@ } }, "/agent-status": { + "deprecated": true, + "get": { + "summary": "Agents - Summary stats", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "integer" + }, + "events": { + "type": "integer" + }, + "inactive": { + "type": "integer" + }, + "offline": { + "type": "integer" + }, + "online": { + "type": "integer" + }, + "other": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "updating": { + "type": "integer" + } + }, + "required": [ + "error", + "events", + "inactive", + "offline", + "online", + "other", + "total", + "updating" + ] + } + } + } + } + }, + "operationId": "get-agent-status", + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "policyId", + "in": "query", + "required": false + } + ] + } + }, + "/agent_status": { "get": { "summary": "Agents - Summary stats", "tags": [], @@ -496,6 +767,13 @@ "type": "object", "properties": { "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/agent" + }, + "deprecated": true + }, + "items": { "type": "array", "items": { "$ref": "#/components/schemas/agent" @@ -512,7 +790,7 @@ } }, "required": [ - "list", + "items", "total", "page", "perPage" @@ -1294,6 +1572,7 @@ "parameters": [] }, "/enrollment-api-keys": { + "deprecated": true, "get": { "summary": "Enrollment API Keys - List", "tags": [], @@ -1306,6 +1585,13 @@ "type": "object", "properties": { "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/enrollment_api_key" + }, + "deprecated": true + }, + "items": { "type": "array", "items": { "$ref": "#/components/schemas/enrollment_api_key" @@ -1322,7 +1608,7 @@ } }, "required": [ - "list", + "items", "page", "perPage", "total" @@ -1370,6 +1656,160 @@ } }, "/enrollment-api-keys/{keyId}": { + "deprecated": true, + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "keyId", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Enrollment API Key - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/enrollment_api_key" + } + }, + "required": [ + "item" + ] + } + } + } + } + }, + "operationId": "get-enrollment-api-key" + }, + "delete": { + "summary": "Enrollment API Key - Delete", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "deleted" + ] + } + }, + "required": [ + "action" + ] + } + } + } + } + }, + "operationId": "delete-enrollment-api-key", + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ] + } + }, + "/enrollment_api_keys": { + "get": { + "summary": "Enrollment API Keys - List", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/enrollment_api_key" + }, + "deprecated": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/enrollment_api_key" + } + }, + "page": { + "type": "number" + }, + "perPage": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "items", + "page", + "perPage", + "total" + ] + } + } + } + } + }, + "operationId": "get-enrollment-api-keys", + "parameters": [] + }, + "post": { + "summary": "Enrollment API Key - Create", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/enrollment_api_key" + }, + "action": { + "type": "string", + "enum": [ + "created" + ] + } + } + } + } + } + } + }, + "operationId": "create-enrollment-api-keys", + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ] + } + }, + "/enrollment_api_keys/{keyId}": { "parameters": [ { "schema": { @@ -2520,10 +2960,6 @@ "unenrollment_started_at": { "type": "string" }, - "shared_id": { - "type": "string", - "deprecated": true - }, "access_api_key_id": { "type": "string" }, diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 44242423aa420..13ffa77279c21 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -100,6 +100,7 @@ paths: operationId: list-all-packages parameters: [] /epm/packages/{pkgkey}: + deprecated: true get: summary: Packages - Info tags: [] @@ -213,6 +214,125 @@ paths: properties: force: type: boolean + /epm/packages/{pkgName}/{pkgVersion}: + get: + summary: Packages - Info + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + allOf: + - properties: + item: + $ref: '#/components/schemas/package_info' + - properties: + status: + type: string + enum: + - installed + - installing + - install_failed + - not_installed + savedObject: + type: string + required: + - status + - savedObject + operationId: get-package + security: + - basicAuth: [] + parameters: + - schema: + type: string + name: pkgName + in: path + required: true + - schema: + type: string + name: pkgVersion + in: path + required: true + post: + summary: Packages - Install + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + type: + oneOf: + - $ref: '#/components/schemas/kibana_saved_object_type' + - $ref: '#/components/schemas/elasticsearch_asset_type' + required: + - id + - type + required: + - items + operationId: install-package + description: '' + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean + delete: + summary: Packages - Delete + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + type: + oneOf: + - $ref: '#/components/schemas/kibana_saved_object_type' + - $ref: '#/components/schemas/elasticsearch_asset_type' + required: + - id + - type + required: + - items + operationId: delete-package + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean /agents/setup: get: summary: Agents setup - Info @@ -253,6 +373,51 @@ paths: parameters: - $ref: '#/components/parameters/kbn_xsrf' /agent-status: + deprecated: true + get: + summary: Agents - Summary stats + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + error: + type: integer + events: + type: integer + inactive: + type: integer + offline: + type: integer + online: + type: integer + other: + type: integer + total: + type: integer + updating: + type: integer + required: + - error + - events + - inactive + - offline + - online + - other + - total + - updating + operationId: get-agent-status + parameters: + - schema: + type: string + name: policyId + in: query + required: false + /agent_status: get: summary: Agents - Summary stats tags: [] @@ -312,6 +477,11 @@ paths: type: array items: $ref: '#/components/schemas/agent' + deprecated: true + items: + type: array + items: + $ref: '#/components/schemas/agent' total: type: number page: @@ -319,7 +489,7 @@ paths: perPage: type: number required: - - list + - items - total - page - perPage @@ -784,6 +954,7 @@ paths: - $ref: '#/components/parameters/kbn_xsrf' parameters: [] /enrollment-api-keys: + deprecated: true get: summary: Enrollment API Keys - List tags: [] @@ -799,6 +970,11 @@ paths: type: array items: $ref: '#/components/schemas/enrollment_api_key' + deprecated: true + items: + type: array + items: + $ref: '#/components/schemas/enrollment_api_key' page: type: number perPage: @@ -806,7 +982,7 @@ paths: total: type: number required: - - list + - items - page - perPage - total @@ -833,6 +1009,104 @@ paths: parameters: - $ref: '#/components/parameters/kbn_xsrf' /enrollment-api-keys/{keyId}: + deprecated: true + parameters: + - schema: + type: string + name: keyId + in: path + required: true + get: + summary: Enrollment API Key - Info + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/enrollment_api_key' + required: + - item + operationId: get-enrollment-api-key + delete: + summary: Enrollment API Key - Delete + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: + - deleted + required: + - action + operationId: delete-enrollment-api-key + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + /enrollment_api_keys: + get: + summary: Enrollment API Keys - List + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + list: + type: array + items: + $ref: '#/components/schemas/enrollment_api_key' + deprecated: true + items: + type: array + items: + $ref: '#/components/schemas/enrollment_api_key' + page: + type: number + perPage: + type: number + total: + type: number + required: + - items + - page + - perPage + - total + operationId: get-enrollment-api-keys + parameters: [] + post: + summary: Enrollment API Key - Create + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/enrollment_api_key' + action: + type: string + enum: + - created + operationId: create-enrollment-api-keys + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + /enrollment_api_keys/{keyId}: parameters: - schema: type: string @@ -1582,9 +1856,6 @@ components: type: string unenrollment_started_at: type: string - shared_id: - type: string - deprecated: true access_api_key_id: type: string default_api_key_id: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent.yaml index c21651ca7f8be..72679dd1dab64 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent.yaml @@ -11,9 +11,6 @@ properties: type: string unenrollment_started_at: type: string - shared_id: - type: string - deprecated: true access_api_key_id: type: string default_api_key_id: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml index e695f0048e6ad..a91ec2cb14e94 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml @@ -18,6 +18,8 @@ properties: type: string ca_sha256: type: string + ca_trusted_fingerprint: + type: string api_key: type: string config: diff --git a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml index 5495f2b3ccacf..8dbf54582299a 100644 --- a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml +++ b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml @@ -25,11 +25,17 @@ paths: $ref: paths/epm@packages.yaml '/epm/packages/{pkgkey}': $ref: 'paths/epm@packages@{pkgkey}.yaml' + deprecated: true + '/epm/packages/{pkgName}/{pkgVersion}': + $ref: 'paths/epm@packages@{pkg_name}@{pkg_version}.yaml' # Agent-related endpoints /agents/setup: $ref: paths/agents@setup.yaml /agent-status: $ref: paths/agent_status.yaml + deprecated: true + /agent_status: + $ref: paths/agent_status.yaml /agents: $ref: paths/agents.yaml /agents/bulk_upgrade: @@ -56,7 +62,13 @@ paths: $ref: paths/agent_policies@delete.yaml /enrollment-api-keys: $ref: paths/enrollment_api_keys.yaml + deprecated: true '/enrollment-api-keys/{keyId}': + $ref: 'paths/enrollment_api_keys@{key_id}.yaml' + deprecated: true + /enrollment_api_keys: + $ref: paths/enrollment_api_keys.yaml + '/enrollment_api_keys/{keyId}': $ref: 'paths/enrollment_api_keys@{key_id}.yaml' /package_policies: $ref: paths/package_policies.yaml diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents.yaml index 4a217eda5c5ed..19ea27956dac3 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents.yaml @@ -13,6 +13,11 @@ get: type: array items: $ref: ../components/schemas/agent.yaml + deprecated: true + items: + type: array + items: + $ref: ../components/schemas/agent.yaml total: type: number page: @@ -20,7 +25,7 @@ get: perPage: type: number required: - - list + - items - total - page - perPage diff --git a/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys.yaml b/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys.yaml index 6cfbede4a7ead..9f6ac6de0ebd6 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys.yaml @@ -13,6 +13,11 @@ get: type: array items: $ref: ../components/schemas/enrollment_api_key.yaml + deprecated: true + items: + type: array + items: + $ref: ../components/schemas/enrollment_api_key.yaml page: type: number perPage: @@ -20,7 +25,7 @@ get: total: type: number required: - - list + - items - page - perPage - total diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml new file mode 100644 index 0000000000000..1c3c92d99ab38 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml @@ -0,0 +1,118 @@ +get: + summary: Packages - Info + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + allOf: + - properties: + item: + $ref: ../components/schemas/package_info.yaml + - properties: + status: + type: string + enum: + - installed + - installing + - install_failed + - not_installed + savedObject: + type: string + required: + - status + - savedObject + operationId: get-package + security: + - basicAuth: [] +parameters: + - schema: + type: string + name: pkgName + in: path + required: true + - schema: + type: string + name: pkgVersion + in: path + required: true +post: + summary: Packages - Install + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + type: + oneOf: + - $ref: ../components/schemas/kibana_saved_object_type.yaml + - $ref: ../components/schemas/elasticsearch_asset_type.yaml + required: + - id + - type + required: + - items + operationId: install-package + description: '' + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean +delete: + summary: Packages - Delete + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + type: + oneOf: + - $ref: ../components/schemas/kibana_saved_object_type.yaml + - $ref: ../components/schemas/elasticsearch_asset_type.yaml + required: + - id + - type + required: + - items + operationId: delete-package + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean diff --git a/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml index 326a65692a03b..d70c78dd7de56 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml @@ -61,6 +61,8 @@ put: type: string ca_sha256: type: string + ca_trusted_fingerprint: + type: string config_yaml: type: string required: diff --git a/x-pack/plugins/fleet/common/services/index.ts b/x-pack/plugins/fleet/common/services/index.ts index ba3fb44753643..7698308270fff 100644 --- a/x-pack/plugins/fleet/common/services/index.ts +++ b/x-pack/plugins/fleet/common/services/index.ts @@ -34,3 +34,4 @@ export { } from './validate_package_policy'; export { normalizeHostsForAgents } from './hosts_utils'; +export { splitPkgKey } from './split_pkg_key'; diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index 8ab02c462cfa4..d7954aff70dd2 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -33,8 +33,11 @@ export const epmRouteService = { return EPM_API_ROUTES.LIMITED_LIST_PATTERN; }, - getInfoPath: (pkgkey: string) => { - return EPM_API_ROUTES.INFO_PATTERN.replace('{pkgkey}', pkgkey); + getInfoPath: (pkgName: string, pkgVersion: string) => { + return EPM_API_ROUTES.INFO_PATTERN.replace('{pkgName}', pkgName).replace( + '{pkgVersion}', + pkgVersion + ); }, getStatsPath: (pkgName: string) => { @@ -45,23 +48,27 @@ export const epmRouteService = { return `${EPM_API_ROOT}${filePath.replace('/package', '/packages')}`; }, - getInstallPath: (pkgkey: string) => { - return EPM_API_ROUTES.INSTALL_FROM_REGISTRY_PATTERN.replace('{pkgkey}', pkgkey).replace( - /\/$/, - '' - ); // trim trailing slash + getInstallPath: (pkgName: string, pkgVersion: string) => { + return EPM_API_ROUTES.INSTALL_FROM_REGISTRY_PATTERN.replace('{pkgName}', pkgName) + .replace('{pkgVersion}', pkgVersion) + .replace(/\/$/, ''); // trim trailing slash }, getBulkInstallPath: () => { return EPM_API_ROUTES.BULK_INSTALL_PATTERN; }, - getRemovePath: (pkgkey: string) => { - return EPM_API_ROUTES.DELETE_PATTERN.replace('{pkgkey}', pkgkey).replace(/\/$/, ''); // trim trailing slash + getRemovePath: (pkgName: string, pkgVersion: string) => { + return EPM_API_ROUTES.DELETE_PATTERN.replace('{pkgName}', pkgName) + .replace('{pkgVersion}', pkgVersion) + .replace(/\/$/, ''); // trim trailing slash }, - getUpdatePath: (pkgkey: string) => { - return EPM_API_ROUTES.INFO_PATTERN.replace('{pkgkey}', pkgkey); + getUpdatePath: (pkgName: string, pkgVersion: string) => { + return EPM_API_ROUTES.INFO_PATTERN.replace('{pkgName}', pkgName).replace( + '{pkgVersion}', + pkgVersion + ); }, }; diff --git a/x-pack/plugins/fleet/common/services/split_pkg_key.ts b/x-pack/plugins/fleet/common/services/split_pkg_key.ts new file mode 100644 index 0000000000000..8bbc5b37a2e41 --- /dev/null +++ b/x-pack/plugins/fleet/common/services/split_pkg_key.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverValid from 'semver/functions/valid'; + +/** + * Extract the package name and package version from a string. + * + * @param pkgkey a string containing the package name delimited by the package version + */ +export function splitPkgKey(pkgkey: string): { pkgName: string; pkgVersion: string } { + // If no version is provided, use the provided package key as the + // package name and return an empty version value + if (!pkgkey.includes('-')) { + return { pkgName: pkgkey, pkgVersion: '' }; + } + + const pkgName = pkgkey.includes('-') ? pkgkey.substr(0, pkgkey.indexOf('-')) : pkgkey; + + if (pkgName === '') { + throw new Error('Package key parsing failed: package name was empty'); + } + + // this will return the entire string if `indexOf` return -1 + const pkgVersion = pkgkey.substr(pkgkey.indexOf('-') + 1); + if (!semverValid(pkgVersion)) { + throw new Error('Package key parsing failed: package version was not a valid semver'); + } + return { pkgName, pkgVersion }; +} diff --git a/x-pack/plugins/fleet/common/types/models/output.ts b/x-pack/plugins/fleet/common/types/models/output.ts index fada8171b91fc..2ff50c0fc7bdb 100644 --- a/x-pack/plugins/fleet/common/types/models/output.ts +++ b/x-pack/plugins/fleet/common/types/models/output.ts @@ -17,6 +17,7 @@ export interface NewOutput { type: ValueOf; hosts?: string[]; ca_sha256?: string; + ca_trusted_fingerprint?: string; api_key?: string; config_yaml?: string; is_preconfigured?: boolean; diff --git a/x-pack/plugins/fleet/common/types/models/package_policy.ts b/x-pack/plugins/fleet/common/types/models/package_policy.ts index df484646ef66b..75932fd4a790a 100644 --- a/x-pack/plugins/fleet/common/types/models/package_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/package_policy.ts @@ -56,6 +56,7 @@ export interface PackagePolicyInput extends Omit> & { + id?: string | number; name: string; package: Partial & { name: string }; inputs?: InputsOverride[]; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index e6da9d4498ce2..5e091b9c543f2 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -7,22 +7,19 @@ import type { Agent, AgentAction, NewAgentAction } from '../models'; +import type { ListResult, ListWithKuery } from './common'; + export interface GetAgentsRequest { - query: { - page: number; - perPage: number; - kuery?: string; + query: ListWithKuery & { showInactive: boolean; showUpgradeable?: boolean; }; } -export interface GetAgentsResponse { - list: Agent[]; - total: number; +export interface GetAgentsResponse extends ListResult { totalInactive: number; - page: number; - perPage: number; + // deprecated in 8.x + list?: Agent[]; } export interface GetOneAgentRequest { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent_policy.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent_policy.ts index 0975b1e28fb8b..cbf3c9806d388 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent_policy.ts @@ -7,7 +7,7 @@ import type { AgentPolicy, NewAgentPolicy, FullAgentPolicy } from '../models'; -import type { ListWithKuery } from './common'; +import type { ListResult, ListWithKuery } from './common'; export interface GetAgentPoliciesRequest { query: ListWithKuery & { @@ -17,12 +17,7 @@ export interface GetAgentPoliciesRequest { export type GetAgentPoliciesResponseItem = AgentPolicy & { agents?: number }; -export interface GetAgentPoliciesResponse { - items: GetAgentPoliciesResponseItem[]; - total: number; - page: number; - perPage: number; -} +export type GetAgentPoliciesResponse = ListResult; export interface GetOneAgentPolicyRequest { params: { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/enrollment_api_key.ts b/x-pack/plugins/fleet/common/types/rest_spec/enrollment_api_key.ts index da870deb31d9c..7fa724e5079c8 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/enrollment_api_key.ts @@ -7,20 +7,16 @@ import type { EnrollmentAPIKey } from '../models'; +import type { ListResult, ListWithKuery } from './common'; + export interface GetEnrollmentAPIKeysRequest { - query: { - page: number; - perPage: number; - kuery?: string; - }; + query: ListWithKuery; } -export interface GetEnrollmentAPIKeysResponse { - list: EnrollmentAPIKey[]; - total: number; - page: number; - perPage: number; -} +export type GetEnrollmentAPIKeysResponse = ListResult & { + // deprecated in 8.x + list?: EnrollmentAPIKey[]; +}; export interface GetOneEnrollmentAPIKeyRequest { params: { 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 cfe0b4abdcd3c..6a72792e780ef 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts @@ -22,7 +22,9 @@ export interface GetCategoriesRequest { } export interface GetCategoriesResponse { - response: CategorySummaryList; + items: CategorySummaryList; + // deprecated in 8.0 + response?: CategorySummaryList; } export interface GetPackagesRequest { @@ -33,33 +35,46 @@ export interface GetPackagesRequest { } export interface GetPackagesResponse { - response: PackageList; + items: PackageList; + // deprecated in 8.0 + response?: PackageList; } export interface GetLimitedPackagesResponse { - response: string[]; + items: string[]; + // deprecated in 8.0 + response?: string[]; } export interface GetFileRequest { params: { - pkgkey: string; + pkgName: string; + pkgVersion: string; filePath: string; }; } export interface GetInfoRequest { params: { - pkgkey: string; + // deprecated in 8.0 + pkgkey?: string; + pkgName: string; + pkgVersion: string; }; } export interface GetInfoResponse { - response: PackageInfo; + item: PackageInfo; + // deprecated in 8.0 + response?: PackageInfo; } export interface UpdatePackageRequest { params: { - pkgkey: string; + // deprecated in 8.0 + pkgkey?: string; + pkgName: string; + pkgVersion: string; }; body: { keepPoliciesUpToDate?: boolean; @@ -67,7 +82,9 @@ export interface UpdatePackageRequest { } export interface UpdatePackageResponse { - response: PackageInfo; + item: PackageInfo; + // deprecated in 8.0 + response?: PackageInfo; } export interface GetStatsRequest { @@ -82,12 +99,17 @@ export interface GetStatsResponse { export interface InstallPackageRequest { params: { - pkgkey: string; + // deprecated in 8.0 + pkgkey?: string; + pkgName: string; + pkgVersion: string; }; } export interface InstallPackageResponse { - response: AssetReference[]; + items: AssetReference[]; + // deprecated in 8.0 + response?: AssetReference[]; } export interface IBulkInstallPackageHTTPError { @@ -110,7 +132,9 @@ export interface BulkInstallPackageInfo { } export interface BulkInstallPackagesResponse { - response: Array; + items: Array; + // deprecated in 8.0 + response?: Array; } export interface BulkInstallPackagesRequest { @@ -125,10 +149,15 @@ export interface MessageResponse { export interface DeletePackageRequest { params: { - pkgkey: string; + // deprecated in 8.0 + pkgkey?: string; + pkgName: string; + pkgVersion: string; }; } export interface DeletePackageResponse { - response: AssetReference[]; + // deprecated in 8.0 + response?: AssetReference[]; + items: AssetReference[]; } diff --git a/x-pack/plugins/fleet/common/types/rest_spec/output.ts b/x-pack/plugins/fleet/common/types/rest_spec/output.ts index 4e380feeb83a8..9a5001a3af10b 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/output.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/output.ts @@ -7,6 +7,8 @@ import type { Output } from '../models'; +import type { ListResult } from './common'; + export interface GetOneOutputResponse { item: Output; } @@ -30,6 +32,7 @@ export interface PutOutputRequest { name?: string; hosts?: string[]; ca_sha256?: string; + ca_trusted_fingerprint?: string; config_yaml?: string; is_default?: boolean; is_default_monitoring?: boolean; @@ -43,6 +46,7 @@ export interface PostOutputRequest { name: string; hosts?: string[]; ca_sha256?: string; + ca_trusted_fingerprint?: string; is_default?: boolean; is_default_monitoring?: boolean; config_yaml?: string; @@ -53,9 +57,4 @@ export interface PutOutputResponse { item: Output; } -export interface GetOutputsResponse { - items: Output[]; - total: number; - page: number; - perPage: number; -} +export type GetOutputsResponse = ListResult; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/package_policy.ts b/x-pack/plugins/fleet/common/types/rest_spec/package_policy.ts index b050a7c798a0b..9eb20383d57bd 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/package_policy.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/package_policy.ts @@ -13,20 +13,13 @@ import type { PackagePolicyPackage, } from '../models'; +import type { ListResult, ListWithKuery } from './common'; + export interface GetPackagePoliciesRequest { - query: { - page: number; - perPage: number; - kuery?: string; - }; + query: ListWithKuery; } -export interface GetPackagePoliciesResponse { - items: PackagePolicy[]; - total: number; - page: number; - perPage: number; -} +export type GetPackagePoliciesResponse = ListResult; export interface GetOnePackagePolicyRequest { params: { diff --git a/x-pack/plugins/fleet/cypress/fixtures/integrations/apache.json b/x-pack/plugins/fleet/cypress/fixtures/integrations/apache.json index 3b78048fdd83f..397bc6d653409 100644 --- a/x-pack/plugins/fleet/cypress/fixtures/integrations/apache.json +++ b/x-pack/plugins/fleet/cypress/fixtures/integrations/apache.json @@ -1,5 +1,5 @@ { - "response": { + "item": { "name": "apache", "title": "Apache", "version": "1.1.0", diff --git a/x-pack/plugins/fleet/cypress/integration/integrations.spec.ts b/x-pack/plugins/fleet/cypress/integration/integrations.spec.ts index 88769ece39f2f..8b1a5e97279e8 100644 --- a/x-pack/plugins/fleet/cypress/integration/integrations.spec.ts +++ b/x-pack/plugins/fleet/cypress/integration/integrations.spec.ts @@ -88,7 +88,7 @@ describe('Add Integration', () => { fixture: 'integrations/agent_policy.json', }); // TODO fixture includes 1 package policy, should be empty initially - cy.intercept('GET', '/api/fleet/epm/packages/apache-1.1.0', { + cy.intercept('GET', '/api/fleet/epm/packages/apache/1.1.0', { fixture: 'integrations/apache.json', }); addAndVerifyIntegration(); diff --git a/x-pack/plugins/fleet/cypress/tasks/integrations.ts b/x-pack/plugins/fleet/cypress/tasks/integrations.ts index f1c891fa1186c..e9e3f2613c3e8 100644 --- a/x-pack/plugins/fleet/cypress/tasks/integrations.ts +++ b/x-pack/plugins/fleet/cypress/tasks/integrations.ts @@ -50,7 +50,7 @@ export const deleteIntegrations = async (integration: string) => { export const installPackageWithVersion = (integration: string, version: string) => { cy.request({ - url: `/api/fleet/epm/packages/${integration}-${version}`, + url: `/api/fleet/epm/packages/${integration}/${version}`, headers: { 'kbn-xsrf': 'cypress' }, body: '{ "force": true }', method: 'POST', diff --git a/x-pack/plugins/fleet/dev_docs/api/epm.md b/x-pack/plugins/fleet/dev_docs/api/epm.md index 90b636a5a92a1..1588e228c438b 100644 --- a/x-pack/plugins/fleet/dev_docs/api/epm.md +++ b/x-pack/plugins/fleet/dev_docs/api/epm.md @@ -14,11 +14,11 @@ curl localhost:5601/api/fleet/epm/packages Install a package: ``` -curl -X POST localhost:5601/api/fleet/epm/packages/iptables-1.0.4 +curl -X POST localhost:5601/api/fleet/epm/packages/iptables/1.0.4 ``` Delete a package: ``` -curl -X DELETE localhost:5601/api/fleet/epm/packages/iptables-1.0.4 +curl -X DELETE localhost:5601/api/fleet/epm/packages/iptables/1.0.4 ``` 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 4f1211a83ebba..c731936c775e5 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 @@ -24,6 +24,7 @@ import { import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; import { safeLoad } from 'js-yaml'; +import { splitPkgKey } from '../../../../../../common'; import type { AgentPolicy, NewPackagePolicy, @@ -152,15 +153,16 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { // Form state const [formState, setFormState] = useState('VALID'); + const { pkgName, pkgVersion } = splitPkgKey(params.pkgkey); // Fetch package info const { data: packageInfoData, error: packageInfoError, isLoading: isPackageInfoLoading, - } = useGetPackageInfoByKey(params.pkgkey); + } = useGetPackageInfoByKey(pkgName, pkgVersion); const packageInfo = useMemo(() => { - if (packageInfoData && packageInfoData.response) { - return packageInfoData.response; + if (packageInfoData && packageInfoData.item) { + return packageInfoData.item; } }, [packageInfoData]); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index c0914e41872b1..8d7ac07867605 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -213,15 +213,16 @@ export const EditPackagePolicyForm = memo<{ } const { data: packageData } = await sendGetPackageInfoByKey( - pkgKeyFromPackageInfo(_packageInfo!) + _packageInfo!.name, + _packageInfo!.version ); - if (packageData?.response) { - setPackageInfo(packageData.response); + if (packageData?.item) { + setPackageInfo(packageData.item); const newValidationResults = validatePackagePolicy( newPackagePolicy, - packageData.response, + packageData.item, safeLoad ); setValidationResults(newValidationResults); @@ -348,7 +349,8 @@ export const EditPackagePolicyForm = memo<{ const [formState, setFormState] = useState('INVALID'); const savePackagePolicy = async () => { setFormState('LOADING'); - const result = await sendUpdatePackagePolicy(packagePolicyId, packagePolicy); + const { elasticsearch, ...restPackagePolicy } = packagePolicy; // ignore 'elasticsearch' property since it fails route validation + const result = await sendUpdatePackagePolicy(packagePolicyId, restPackagePolicy); setFormState('SUBMITTED'); return result; }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 0dbe947369ad3..c64d065c1e058 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -241,7 +241,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { .join(' or '); if (kueryBuilder) { - kueryBuilder = `(${kueryBuilder}) and ${kueryStatus}`; + kueryBuilder = `(${kueryBuilder}) and (${kueryStatus})`; } else { kueryBuilder = kueryStatus; } @@ -308,7 +308,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { inactive: agentsRequest.data.totalInactive, }); - setAgents(agentsRequest.data.list); + setAgents(agentsRequest.data.items); setTotalAgents(agentsRequest.data.total); setTotalInactiveAgents(agentsRequest.data.totalInactive); } catch (error) { 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 4efff98fe39b2..b2eaf904ee1bb 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 @@ -245,6 +245,7 @@ export const useFleetServerInstructions = (policyId?: string) => { const { data: settings, resendRequest: refreshSettings } = useGetSettings(); const fleetServerHost = settings?.item.fleet_server_hosts?.[0]; const esHost = output?.hosts?.[0]; + const sslCATrustedFingerprint: string | undefined = output?.ca_trusted_fingerprint; const installCommand = useMemo((): string => { if (!serviceToken || !esHost) { @@ -257,9 +258,18 @@ export const useFleetServerInstructions = (policyId?: string) => { serviceToken, policyId, fleetServerHost, - deploymentMode === 'production' + deploymentMode === 'production', + sslCATrustedFingerprint ); - }, [serviceToken, esHost, platform, policyId, fleetServerHost, deploymentMode]); + }, [ + serviceToken, + esHost, + platform, + policyId, + fleetServerHost, + deploymentMode, + sslCATrustedFingerprint, + ]); const getServiceToken = useCallback(async () => { setIsLoadingServiceToken(true); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts index 62580a1445f06..d05107e5058d4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts @@ -17,7 +17,7 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "sudo ./elastic-agent install \\\\ + "sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" `); @@ -31,7 +31,7 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install \` + ".\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1" `); @@ -45,11 +45,30 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "sudo elastic-agent enroll \\\\ + "sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" `); }); + + it('should return the correct command sslCATrustedFingerprint option is passed', () => { + const res = getInstallCommandForPlatform( + 'linux-mac', + 'http://elasticsearch:9200', + 'service-token-1', + undefined, + undefined, + false, + 'fingerprint123456' + ); + + expect(res).toMatchInlineSnapshot(` + "sudo ./elastic-agent install \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-es-ca-trusted-fingerprint=fingerprint123456" + `); + }); }); describe('with policy id', () => { @@ -62,7 +81,7 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "sudo ./elastic-agent install \\\\ + "sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ --fleet-server-policy=policy-1" @@ -78,7 +97,7 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install \` + ".\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1 \` --fleet-server-policy=policy-1" @@ -94,7 +113,7 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "sudo elastic-agent enroll \\\\ + "sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ --fleet-server-policy=policy-1" @@ -178,7 +197,7 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "sudo elastic-agent enroll \\\\ + "sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" `); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts index f5c40e8071691..64ae4903af53f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts @@ -13,37 +13,49 @@ export function getInstallCommandForPlatform( serviceToken: string, policyId?: string, fleetServerHost?: string, - isProductionDeployment?: boolean + isProductionDeployment?: boolean, + sslCATrustedFingerprint?: string ) { - let commandArguments = ''; - const newLineSeparator = platform === 'windows' ? '`' : '\\'; + const commandArguments = []; + const newLineSeparator = platform === 'windows' ? '`\n' : '\\\n'; if (isProductionDeployment && fleetServerHost) { - commandArguments += `--url=${fleetServerHost} ${newLineSeparator}\n`; - } else { - commandArguments += ` ${newLineSeparator}\n`; + commandArguments.push(['url', fleetServerHost]); } - commandArguments += ` --fleet-server-es=${esHost}`; - commandArguments += ` ${newLineSeparator}\n --fleet-server-service-token=${serviceToken}`; + commandArguments.push(['fleet-server-es', esHost]); + commandArguments.push(['fleet-server-service-token', serviceToken]); if (policyId) { - commandArguments += ` ${newLineSeparator}\n --fleet-server-policy=${policyId}`; + commandArguments.push(['fleet-server-policy', policyId]); + } + + if (sslCATrustedFingerprint) { + commandArguments.push(['fleet-server-es-ca-trusted-fingerprint', sslCATrustedFingerprint]); } if (isProductionDeployment) { - commandArguments += ` ${newLineSeparator}\n --certificate-authorities=`; - commandArguments += ` ${newLineSeparator}\n --fleet-server-es-ca=`; - commandArguments += ` ${newLineSeparator}\n --fleet-server-cert=`; - commandArguments += ` ${newLineSeparator}\n --fleet-server-cert-key=`; + commandArguments.push(['certificate-authorities', '']); + if (!sslCATrustedFingerprint) { + commandArguments.push(['fleet-server-es-ca', '']); + } + commandArguments.push(['fleet-server-cert', '']); + commandArguments.push(['fleet-server-cert-key', '']); } + const commandArgumentsStr = commandArguments.reduce((acc, [key, val]) => { + if (acc === '' && key === 'url') { + return `--${key}=${val}`; + } + return (acc += ` ${newLineSeparator} --${key}=${val}`); + }, ''); + switch (platform) { case 'linux-mac': - return `sudo ./elastic-agent install ${commandArguments}`; + return `sudo ./elastic-agent install ${commandArgumentsStr}`; case 'windows': - return `.\\elastic-agent.exe install ${commandArguments}`; + return `.\\elastic-agent.exe install ${commandArgumentsStr}`; case 'rpm-deb': - return `sudo elastic-agent enroll ${commandArguments}`; + return `sudo elastic-agent enroll ${commandArgumentsStr}`; default: return ''; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx index 2d963ea0ddf30..5902f73cae3bc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx @@ -63,7 +63,7 @@ export const FleetServerUpgradeModal: React.FunctionComponent = ({ onClos throw res.error; } - for (const agent of res.data?.list ?? []) { + for (const agent of res.data?.items ?? []) { if (!agent.policy_id || agentPoliciesAlreadyChecked[agent.policy_id]) { continue; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx index b8b66b42b533d..72160fb4ae897 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx @@ -182,7 +182,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { const total = enrollmentAPIKeysRequest?.data?.total ?? 0; const rowItems = - enrollmentAPIKeysRequest?.data?.list.filter((enrollmentKey) => { + enrollmentAPIKeysRequest?.data?.items.filter((enrollmentKey) => { if (!agentPolicies.length || !enrollmentKey.policy_id) return false; const agentPolicy = agentPoliciesById[enrollmentKey.policy_id]; return !agentPolicy?.is_managed; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/hooks/use_fleet_server_unhealthy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/hooks/use_fleet_server_unhealthy.test.tsx index 87a269672ed9c..a36b4fb25793f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/hooks/use_fleet_server_unhealthy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/hooks/use_fleet_server_unhealthy.test.tsx @@ -37,7 +37,7 @@ const mockApiCallsWithHealthyFleetServer = (http: MockedFleetStartServices['http }; } - if (path === '/api/fleet/agent-status') { + if (path === '/api/fleet/agent_status') { return { data: { results: { online: 1, updating: 0, offline: 0 }, @@ -65,7 +65,7 @@ const mockApiCallsWithoutHealthyFleetServer = (http: MockedFleetStartServices['h }; } - if (path === '/api/fleet/agent-status') { + if (path === '/api/fleet/agent_status') { return { data: { results: { online: 0, updating: 0, offline: 1 }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx index 824eec081e28b..62b22d0bdffc6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx @@ -135,6 +135,27 @@ export const EditOutputFlyout: React.FunctionComponent = })} {...inputs.elasticsearchUrlInput.props} /> + + } + {...inputs.caTrustedFingerprintInput.formRowProps} + > + + ( void, output?: Output) { isPreconfigured ); + const caTrustedFingerprintInput = useInput( + output?.ca_trusted_fingerprint ?? '', + validateCATrustedFingerPrint, + isPreconfigured + ); + const defaultOutputInput = useSwitchInput( output?.is_default ?? false, isPreconfigured || output?.is_default @@ -127,6 +138,7 @@ export function useOutputForm(onSucess: () => void, output?: Output) { additionalYamlConfigInput, defaultOutputInput, defaultMonitoringOutputInput, + caTrustedFingerprintInput, }; const hasChanged = Object.values(inputs).some((input) => input.hasChanged); @@ -135,13 +147,19 @@ export function useOutputForm(onSucess: () => void, output?: Output) { const nameInputValid = nameInput.validate(); const elasticsearchUrlsValid = elasticsearchUrlInput.validate(); const additionalYamlConfigValid = additionalYamlConfigInput.validate(); - - if (!elasticsearchUrlsValid || !additionalYamlConfigValid || !nameInputValid) { + const caTrustedFingerprintValid = caTrustedFingerprintInput.validate(); + + if ( + !elasticsearchUrlsValid || + !additionalYamlConfigValid || + !nameInputValid || + !caTrustedFingerprintValid + ) { return false; } return true; - }, [nameInput, elasticsearchUrlInput, additionalYamlConfigInput]); + }, [nameInput, elasticsearchUrlInput, additionalYamlConfigInput, caTrustedFingerprintInput]); const submit = useCallback(async () => { try { @@ -157,6 +175,7 @@ export function useOutputForm(onSucess: () => void, output?: Output) { is_default: defaultOutputInput.value, is_default_monitoring: defaultMonitoringOutputInput.value, config_yaml: additionalYamlConfigInput.value, + ca_trusted_fingerprint: caTrustedFingerprintInput.value, }; if (output) { @@ -195,6 +214,7 @@ export function useOutputForm(onSucess: () => void, output?: Output) { defaultMonitoringOutputInput.value, defaultOutputInput.value, elasticsearchUrlInput.value, + caTrustedFingerprintInput.value, nameInput.value, notifications.toasts, onSucess, diff --git a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_links.tsx b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_links.tsx index 032554a4ec439..c39e4e0d097c5 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_links.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_links.tsx @@ -39,8 +39,7 @@ export function useLinks() { version: string; }) => { const imagePath = removeRelativePath(path); - const pkgkey = `${packageName}-${version}`; - const filePath = `${epmRouteService.getInfoPath(pkgkey)}/${imagePath}`; + const filePath = `${epmRouteService.getInfoPath(packageName, version)}/${imagePath}`; return http.basePath.prepend(filePath); }, }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_package_install.tsx b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_package_install.tsx index ad6f492bc5fce..90a2231da40c6 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_package_install.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_package_install.tsx @@ -61,9 +61,8 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar const currStatus = getPackageInstallStatus(name); const newStatus = { ...currStatus, name, status: InstallStatus.installing }; setPackageInstallStatus(newStatus); - const pkgkey = `${name}-${version}`; - const res = await sendInstallPackage(pkgkey); + const res = await sendInstallPackage(name, version); if (res.error) { if (fromUpdate) { // if there is an error during update, set it back to the previous version @@ -126,9 +125,8 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar redirectToVersion, }: Pick & { redirectToVersion: string }) => { setPackageInstallStatus({ name, status: InstallStatus.uninstalling, version }); - const pkgkey = `${name}-${version}`; - const res = await sendRemovePackage(pkgkey); + const res = await sendRemovePackage(name, version); if (res.error) { setPackageInstallStatus({ name, status: InstallStatus.installed, version }); notifications.toasts.addWarning({ diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx index d442f8a13e27e..02874f12c6592 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx @@ -75,7 +75,7 @@ describe('when on integration detail', () => { describe('and the package is not installed', () => { beforeEach(() => { const unInstalledPackage = mockedApi.responseProvider.epmGetInfo(); - unInstalledPackage.response.status = 'not_installed'; + unInstalledPackage.item.status = 'not_installed'; mockedApi.responseProvider.epmGetInfo.mockReturnValue(unInstalledPackage); render(); }); @@ -283,7 +283,7 @@ const mockApiCalls = ( // @ts-ignore const epmPackageResponse: GetInfoResponse = { - response: { + item: { name: 'nginx', title: 'Nginx', version: '0.3.7', @@ -770,7 +770,7 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos http.get.mockImplementation(async (path: any) => { if (typeof path === 'string') { - if (path === epmRouteService.getInfoPath(`nginx-0.3.7`)) { + if (path === epmRouteService.getInfoPath(`nginx`, `0.3.7`)) { markApiCallAsHandled(); return mockedApiInterface.responseProvider.epmGetInfo(); } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 1a3a5c7eadd35..cdebc5f8b3ce1 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -27,6 +27,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import semverLt from 'semver/functions/lt'; +import { splitPkgKey } from '../../../../../../../common'; import { useGetPackageInstallStatus, useSetPackageInstallStatus, @@ -132,26 +133,27 @@ export function Detail() { packageInfo.savedObject && semverLt(packageInfo.savedObject.attributes.version, packageInfo.latestVersion); + const { pkgName, pkgVersion } = splitPkgKey(pkgkey); // Fetch package info const { data: packageInfoData, error: packageInfoError, isLoading: packageInfoLoading, - } = useGetPackageInfoByKey(pkgkey); + } = useGetPackageInfoByKey(pkgName, pkgVersion); const isLoading = packageInfoLoading || permissionCheck.isLoading; const showCustomTab = - useUIExtension(packageInfoData?.response.name ?? '', 'package-detail-custom') !== undefined; + useUIExtension(packageInfoData?.item.name ?? '', 'package-detail-custom') !== undefined; // Track install status state useEffect(() => { - if (packageInfoData?.response) { - const packageInfoResponse = packageInfoData.response; + if (packageInfoData?.item) { + const packageInfoResponse = packageInfoData.item; setPackageInfo(packageInfoResponse); let installedVersion; - const { name } = packageInfoData.response; + const { name } = packageInfoData.item; if ('savedObject' in packageInfoResponse) { installedVersion = packageInfoResponse.savedObject.attributes.version; } 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 73c762d34a2cf..a28f63c3f9163 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 @@ -122,7 +122,7 @@ export const SettingsPage: React.FC = memo(({ packageInfo }: Props) => { try { setKeepPoliciesUpToDateSwitchValue((prev) => !prev); - await sendUpdatePackage(`${packageInfo.name}-${packageInfo.version}`, { + await sendUpdatePackage(packageInfo.name, packageInfo.version, { keepPoliciesUpToDate: !keepPoliciesUpToDateSwitchValue, }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx index 81d1701c4a986..7547e06201171 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx @@ -215,7 +215,7 @@ export const AvailablePackages: React.FC = memo(() => { category: '', }); const eprIntegrationList = useMemo( - () => packageListToIntegrationsList(eprPackages?.response || []), + () => packageListToIntegrationsList(eprPackages?.items || []), [eprPackages] ); @@ -256,7 +256,7 @@ export const AvailablePackages: React.FC = memo(() => { ? [] : mergeCategoriesAndCount( eprCategories - ? (eprCategories.response as Array<{ id: string; title: string; count: number }>) + ? (eprCategories.items as Array<{ id: string; title: string; count: number }>) : [], cards ); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx index 3d069c1d0336b..52c4d09a58c56 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx @@ -103,7 +103,7 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ onKeyChange, }) => { const { notifications } = useStartServices(); - const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState( + const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState( [] ); @@ -143,7 +143,7 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ throw new Error('No data while fetching enrollment API keys'); } - const enrollmentAPIKeysResponse = res.data.list.filter( + const enrollmentAPIKeysResponse = res.data.items.filter( (key) => key.policy_id === agentPolicyId && key.active === true ); diff --git a/x-pack/plugins/fleet/public/hooks/use_package_icon_type.ts b/x-pack/plugins/fleet/public/hooks/use_package_icon_type.ts index 1b21b7bfd78eb..733aaef8b9267 100644 --- a/x-pack/plugins/fleet/public/hooks/use_package_icon_type.ts +++ b/x-pack/plugins/fleet/public/hooks/use_package_icon_type.ts @@ -66,11 +66,11 @@ export const usePackageIconType = ({ } if (tryApi && !paramIcons && !iconList) { - sendGetPackageInfoByKey(cacheKey) + sendGetPackageInfoByKey(packageName, version) .catch((error) => undefined) // Ignore API errors .then((res) => { CACHED_ICONS.delete(cacheKey); - setIconList(res?.data?.response?.icons); + setIconList(res?.data?.item?.icons); }); } diff --git a/x-pack/plugins/fleet/public/hooks/use_package_installations.tsx b/x-pack/plugins/fleet/public/hooks/use_package_installations.tsx index f4735e6f85546..4789770b7046f 100644 --- a/x-pack/plugins/fleet/public/hooks/use_package_installations.tsx +++ b/x-pack/plugins/fleet/public/hooks/use_package_installations.tsx @@ -36,9 +36,8 @@ export const usePackageInstallations = () => { }); const allInstalledPackages = useMemo( - () => - (allPackages?.response || []).filter((pkg) => pkg.status === installationStatuses.Installed), - [allPackages?.response] + () => (allPackages?.items || []).filter((pkg) => pkg.status === installationStatuses.Installed), + [allPackages?.items] ); const updatablePackages = useMemo( 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 a7078dd3a3f91..c5e82316e5eb3 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/epm.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/epm.ts @@ -67,9 +67,9 @@ export const useGetLimitedPackages = () => { }); }; -export const useGetPackageInfoByKey = (pkgkey: string) => { +export const useGetPackageInfoByKey = (pkgName: string, pkgVersion: string) => { return useRequest({ - path: epmRouteService.getInfoPath(pkgkey), + path: epmRouteService.getInfoPath(pkgName, pkgVersion), method: 'get', }); }; @@ -81,9 +81,9 @@ export const useGetPackageStats = (pkgName: string) => { }); }; -export const sendGetPackageInfoByKey = (pkgkey: string) => { +export const sendGetPackageInfoByKey = (pkgName: string, pkgVersion: string) => { return sendRequest({ - path: epmRouteService.getInfoPath(pkgkey), + path: epmRouteService.getInfoPath(pkgName, pkgVersion), method: 'get', }); }; @@ -102,23 +102,27 @@ export const sendGetFileByPath = (filePath: string) => { }); }; -export const sendInstallPackage = (pkgkey: string) => { +export const sendInstallPackage = (pkgName: string, pkgVersion: string) => { return sendRequest({ - path: epmRouteService.getInstallPath(pkgkey), + path: epmRouteService.getInstallPath(pkgName, pkgVersion), method: 'post', }); }; -export const sendRemovePackage = (pkgkey: string) => { +export const sendRemovePackage = (pkgName: string, pkgVersion: string) => { return sendRequest({ - path: epmRouteService.getRemovePath(pkgkey), + path: epmRouteService.getRemovePath(pkgName, pkgVersion), method: 'delete', }); }; -export const sendUpdatePackage = (pkgkey: string, body: UpdatePackageRequest['body']) => { +export const sendUpdatePackage = ( + pkgName: string, + pkgVersion: string, + body: UpdatePackageRequest['body'] +) => { return sendRequest({ - path: epmRouteService.getUpdatePath(pkgkey), + path: epmRouteService.getUpdatePath(pkgName, pkgVersion), method: 'put', body, }); diff --git a/x-pack/plugins/fleet/public/search_provider.test.ts b/x-pack/plugins/fleet/public/search_provider.test.ts index 97ed199c44502..43e6d93c8031c 100644 --- a/x-pack/plugins/fleet/public/search_provider.test.ts +++ b/x-pack/plugins/fleet/public/search_provider.test.ts @@ -24,7 +24,7 @@ import { sendGetPackages } from './hooks'; const mockSendGetPackages = sendGetPackages as jest.Mock; -const testResponse: GetPackagesResponse['response'] = [ +const testResponse: GetPackagesResponse['items'] = [ { description: 'test', download: 'test', diff --git a/x-pack/plugins/fleet/public/search_provider.ts b/x-pack/plugins/fleet/public/search_provider.ts index d919462f38c28..fe7cca92cf48d 100644 --- a/x-pack/plugins/fleet/public/search_provider.ts +++ b/x-pack/plugins/fleet/public/search_provider.ts @@ -105,7 +105,7 @@ export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResult const toSearchResults = ( coreStart: CoreStart, - packagesResponse: GetPackagesResponse['response'] + packagesResponse: GetPackagesResponse['items'] ): GlobalSearchProviderResult[] => { return packagesResponse .flatMap( diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index dd77c216413f3..578d4281cba3b 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -122,7 +122,8 @@ export const getAgentsHandler: RequestHandler< : 0; const body: GetAgentsResponse = { - list: agents, + list: agents, // deprecated + items: agents, total, totalInactive, page, diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index db5b01b319e00..7297252ff3230 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -116,6 +116,15 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { }, getAgentStatusForAgentPolicyHandler ); + router.get( + { + path: AGENT_API_ROUTES.STATUS_PATTERN_DEPRECATED, + validate: GetAgentStatusRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getAgentStatusForAgentPolicyHandler + ); + // upgrade agent router.post( { diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index e84f959c02df9..57a9290d00a00 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -23,7 +23,6 @@ import type { DeleteAgentPolicyRequestSchema, GetFullAgentPolicyRequestSchema, } from '../../types'; -import type { AgentPolicy, NewPackagePolicy } from '../../types'; import { FLEET_SYSTEM_PACKAGE } from '../../../common'; import type { GetAgentPoliciesResponse, @@ -112,10 +111,7 @@ export const createAgentPolicyHandler: RequestHandler< try { // eslint-disable-next-line prefer-const - let [agentPolicy, newSysPackagePolicy] = await Promise.all< - AgentPolicy, - NewPackagePolicy | undefined - >([ + let [agentPolicy, newSysPackagePolicy] = await Promise.all([ agentPolicyService.create(soClient, esClient, request.body, { user, }), diff --git a/x-pack/plugins/fleet/server/routes/app/index.ts b/x-pack/plugins/fleet/server/routes/app/index.ts index aa2d61732eed5..cb2a01deecb4f 100644 --- a/x-pack/plugins/fleet/server/routes/app/index.ts +++ b/x-pack/plugins/fleet/server/routes/app/index.ts @@ -90,4 +90,13 @@ export const registerRoutes = (router: IRouter) => { }, generateServiceTokenHandler ); + + router.post( + { + path: APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN_DEPRECATED, + validate: {}, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + generateServiceTokenHandler + ); }; diff --git a/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts b/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts index 0465614c49432..7fef583af50cd 100644 --- a/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts +++ b/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts @@ -36,7 +36,13 @@ export const getEnrollmentApiKeysHandler: RequestHandler< perPage: request.query.perPage, kuery: request.query.kuery, }); - const body: GetEnrollmentAPIKeysResponse = { list: items, total, page, perPage }; + const body: GetEnrollmentAPIKeysResponse = { + list: items, // deprecated + items, + total, + page, + perPage, + }; return response.ok({ body }); } catch (error) { diff --git a/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts b/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts index 6429d4d29d5c9..39665f14484ba 100644 --- a/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts +++ b/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts @@ -61,4 +61,44 @@ export const registerRoutes = (routers: { superuser: FleetRouter; fleetSetup: Fl }, postEnrollmentApiKeyHandler ); + + routers.fleetSetup.get( + { + path: ENROLLMENT_API_KEY_ROUTES.INFO_PATTERN_DEPRECATED, + validate: GetOneEnrollmentAPIKeyRequestSchema, + // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 + // Required to allow elastic/fleet-server to access this API. + // options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getOneEnrollmentApiKeyHandler + ); + + routers.superuser.delete( + { + path: ENROLLMENT_API_KEY_ROUTES.DELETE_PATTERN_DEPRECATED, + validate: DeleteEnrollmentAPIKeyRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + deleteEnrollmentApiKeyHandler + ); + + routers.fleetSetup.get( + { + path: ENROLLMENT_API_KEY_ROUTES.LIST_PATTERN_DEPRECATED, + validate: GetEnrollmentAPIKeysRequestSchema, + // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 + // Required to allow elastic/fleet-server to access this API. + // options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getEnrollmentApiKeysHandler + ); + + routers.superuser.post( + { + path: ENROLLMENT_API_KEY_ROUTES.CREATE_PATTERN_DEPRECATED, + validate: PostEnrollmentAPIKeyRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postEnrollmentApiKeyHandler + ); }; diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index c98038427cafc..4f3f969defd0c 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -9,6 +9,7 @@ import path from 'path'; import type { TypeOf } from '@kbn/config-schema'; import mime from 'mime-types'; +import semverValid from 'semver/functions/valid'; import type { ResponseHeaders, KnownHeaders } from 'src/core/server'; import type { @@ -50,8 +51,11 @@ import { getInstallation, } from '../../services/epm/packages'; import type { BulkInstallResponse } from '../../services/epm/packages'; -import { defaultIngestErrorHandler, ingestErrorToResponseOptions } from '../../errors'; -import { splitPkgKey } from '../../services/epm/registry'; +import { + defaultIngestErrorHandler, + ingestErrorToResponseOptions, + IngestManagerError, +} from '../../errors'; import { licenseService } from '../../services'; import { getArchiveEntry } from '../../services/epm/archive/cache'; import { getAsset } from '../../services/epm/archive/storage'; @@ -65,6 +69,7 @@ export const getCategoriesHandler: FleetRequestHandler< try { const res = await getCategories(request.query); const body: GetCategoriesResponse = { + items: res, response: res, }; return response.ok({ body }); @@ -84,6 +89,7 @@ export const getListHandler: FleetRequestHandler< ...request.query, }); const body: GetPackagesResponse = { + items: res, response: res, }; return response.ok({ @@ -99,6 +105,7 @@ export const getLimitedListHandler: FleetRequestHandler = async (context, reques const savedObjectsClient = context.fleet.epm.internalSoClient; const res = await getLimitedPackages({ savedObjectsClient }); const body: GetLimitedPackagesResponse = { + items: res, response: res, }; return response.ok({ @@ -186,13 +193,18 @@ export const getFileHandler: FleetRequestHandler> = async (context, request, response) => { try { - const { pkgkey } = request.params; const savedObjectsClient = context.fleet.epm.internalSoClient; - // TODO: change epm API to /packageName/version so we don't need to do this - const { pkgName, pkgVersion } = splitPkgKey(pkgkey); - const res = await getPackageInfo({ savedObjectsClient, pkgName, pkgVersion }); + const { pkgName, pkgVersion } = request.params; + if (pkgVersion && !semverValid(pkgVersion)) { + throw new IngestManagerError('Package version is not a valid semver'); + } + const res = await getPackageInfo({ + savedObjectsClient, + pkgName, + pkgVersion: pkgVersion || '', + }); const body: GetInfoResponse = { - response: res, + item: res, }; return response.ok({ body }); } catch (error) { @@ -206,14 +218,12 @@ export const updatePackageHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { try { - const { pkgkey } = request.params; const savedObjectsClient = context.fleet.epm.internalSoClient; - - const { pkgName } = splitPkgKey(pkgkey); + const { pkgName } = request.params; const res = await updatePackage({ savedObjectsClient, pkgName, ...request.body }); const body: UpdatePackageResponse = { - response: res, + item: res, }; return response.ok({ body }); @@ -243,18 +253,18 @@ export const installPackageFromRegistryHandler: FleetRequestHandler< > = async (context, request, response) => { const savedObjectsClient = context.fleet.epm.internalSoClient; const esClient = context.core.elasticsearch.client.asInternalUser; - const { pkgkey } = request.params; + const { pkgName, pkgVersion } = request.params; const res = await installPackage({ installSource: 'registry', savedObjectsClient, - pkgkey, + pkgkey: pkgVersion ? `${pkgName}-${pkgVersion}` : pkgName, esClient, force: request.body?.force, }); if (!res.error) { const body: InstallPackageResponse = { - response: res.assets || [], + items: res.assets || [], }; return response.ok({ body }); } else { @@ -291,6 +301,7 @@ export const bulkInstallPackagesFromRegistryHandler: FleetRequestHandler< }); const payload = bulkInstalledResponses.map(bulkInstallServiceResponseToHttpEntry); const body: BulkInstallPackagesResponse = { + items: payload, response: payload, }; return response.ok({ body }); @@ -321,6 +332,7 @@ export const installPackageByUploadHandler: FleetRequestHandler< }); if (!res.error) { const body: InstallPackageResponse = { + items: res.assets || [], response: res.assets || [], }; return response.ok({ body }); @@ -335,17 +347,18 @@ export const deletePackageHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { try { - const { pkgkey } = request.params; + const { pkgName, pkgVersion } = request.params; const savedObjectsClient = context.fleet.epm.internalSoClient; const esClient = context.core.elasticsearch.client.asInternalUser; const res = await removeInstallation({ savedObjectsClient, - pkgkey, + pkgName, + pkgVersion, esClient, force: request.body?.force, }); const body: DeletePackageResponse = { - response: res, + items: res, }; return response.ok({ body }); } catch (error) { diff --git a/x-pack/plugins/fleet/server/routes/epm/index.ts b/x-pack/plugins/fleet/server/routes/epm/index.ts index a2f2df4a00c55..b07bb2b1ab77b 100644 --- a/x-pack/plugins/fleet/server/routes/epm/index.ts +++ b/x-pack/plugins/fleet/server/routes/epm/index.ts @@ -5,18 +5,32 @@ * 2.0. */ +import type { IKibanaResponse } from 'src/core/server'; + +import type { + DeletePackageResponse, + GetInfoResponse, + InstallPackageResponse, + UpdatePackageResponse, +} from '../../../common'; + import { PLUGIN_ID, EPM_API_ROUTES } from '../../constants'; +import { splitPkgKey } from '../../services/epm/registry'; import { GetCategoriesRequestSchema, GetPackagesRequestSchema, GetFileRequestSchema, GetInfoRequestSchema, + GetInfoRequestSchemaDeprecated, InstallPackageFromRegistryRequestSchema, + InstallPackageFromRegistryRequestSchemaDeprecated, InstallPackageByUploadRequestSchema, DeletePackageRequestSchema, + DeletePackageRequestSchemaDeprecated, BulkUpgradePackagesFromRegistryRequestSchema, GetStatsRequestSchema, UpdatePackageRequestSchema, + UpdatePackageRequestSchemaDeprecated, } from '../../types'; import type { FleetRouter } from '../../types/request_context'; @@ -142,4 +156,86 @@ export const registerRoutes = (routers: { rbac: FleetRouter; superuser: FleetRou }, deletePackageHandler ); + + // deprecated since 8.0 + routers.rbac.get( + { + path: EPM_API_ROUTES.INFO_PATTERN_DEPRECATED, + validate: GetInfoRequestSchemaDeprecated, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + async (context, request, response) => { + const newRequest = { ...request, params: splitPkgKey(request.params.pkgkey) } as any; + const resp: IKibanaResponse = await getInfoHandler( + context, + newRequest, + response + ); + if (resp.payload?.item) { + // returning item as well here, because pkgVersion is optional in new GET endpoint, and if not specified, the router selects the deprecated route + return response.ok({ body: { item: resp.payload.item, response: resp.payload.item } }); + } + return resp; + } + ); + + routers.superuser.put( + { + path: EPM_API_ROUTES.INFO_PATTERN_DEPRECATED, + validate: UpdatePackageRequestSchemaDeprecated, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + async (context, request, response) => { + const newRequest = { ...request, params: splitPkgKey(request.params.pkgkey) } as any; + const resp: IKibanaResponse = await updatePackageHandler( + context, + newRequest, + response + ); + if (resp.payload?.item) { + return response.ok({ body: { response: resp.payload.item } }); + } + return resp; + } + ); + + routers.superuser.post( + { + path: EPM_API_ROUTES.INSTALL_FROM_REGISTRY_PATTERN_DEPRECATED, + validate: InstallPackageFromRegistryRequestSchemaDeprecated, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + async (context, request, response) => { + const newRequest = { ...request, params: splitPkgKey(request.params.pkgkey) } as any; + const resp: IKibanaResponse = await installPackageFromRegistryHandler( + context, + newRequest, + response + ); + if (resp.payload?.items) { + return response.ok({ body: { response: resp.payload.items } }); + } + return resp; + } + ); + + routers.superuser.delete( + { + path: EPM_API_ROUTES.DELETE_PATTERN_DEPRECATED, + validate: DeletePackageRequestSchemaDeprecated, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + async (context, request, response) => { + const newRequest = { ...request, params: splitPkgKey(request.params.pkgkey) } as any; + const resp: IKibanaResponse = await deletePackageHandler( + context, + newRequest, + response + ); + if (resp.payload?.items) { + return response.ok({ body: { response: resp.payload.items } }); + } + return resp; + } + ); }; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 26adf7b9fcbc7..6058cfba12cad 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -113,6 +113,7 @@ const getSavedObjectTypes = ( is_default_monitoring: { type: 'boolean' }, hosts: { type: 'keyword' }, ca_sha256: { type: 'keyword', index: false }, + ca_trusted_fingerprint: { type: 'keyword', index: false }, config: { type: 'flattened' }, config_yaml: { type: 'text' }, is_preconfigured: { type: 'boolean', index: false }, diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts index d720aa72e18f8..1bc1919226248 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts @@ -12,7 +12,7 @@ import type { AgentPolicy, Output } from '../../types'; import { agentPolicyService } from '../agent_policy'; import { agentPolicyUpdateEventHandler } from '../agent_policy_update'; -import { getFullAgentPolicy } from './full_agent_policy'; +import { getFullAgentPolicy, transformOutputToFullPolicyOutput } from './full_agent_policy'; import { getMonitoringPermissions } from './monitoring_permissions'; const mockedGetElasticAgentMonitoringPermissions = getMonitoringPermissions as jest.Mock< @@ -305,3 +305,58 @@ describe('getFullAgentPolicy', () => { expect(agentPolicy?.outputs.default).toBeDefined(); }); }); + +describe('transformOutputToFullPolicyOutput', () => { + it('should works with only required field on a output', () => { + const policyOutput = transformOutputToFullPolicyOutput({ + id: 'id123', + hosts: ['http://host.fr'], + is_default: false, + is_default_monitoring: false, + name: 'test output', + type: 'elasticsearch', + api_key: 'apikey123', + }); + + expect(policyOutput).toMatchInlineSnapshot(` + Object { + "api_key": "apikey123", + "ca_sha256": undefined, + "hosts": Array [ + "http://host.fr", + ], + "type": "elasticsearch", + } + `); + }); + it('should support ca_trusted_fingerprint field on a output', () => { + const policyOutput = transformOutputToFullPolicyOutput({ + id: 'id123', + hosts: ['http://host.fr'], + is_default: false, + is_default_monitoring: false, + name: 'test output', + type: 'elasticsearch', + api_key: 'apikey123', + ca_trusted_fingerprint: 'fingerprint123', + config_yaml: ` +test: 1234 +ssl.test: 123 + `, + }); + + expect(policyOutput).toMatchInlineSnapshot(` + Object { + "api_key": "apikey123", + "ca_sha256": undefined, + "hosts": Array [ + "http://host.fr", + ], + "ssl.ca_trusted_fingerprint": "fingerprint123", + "ssl.test": 123, + "test": 1234, + "type": "elasticsearch", + } + `); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts index f89a186c1a5f9..166b2f77dc27b 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts @@ -168,19 +168,20 @@ export async function getFullAgentPolicy( return fullAgentPolicy; } -function transformOutputToFullPolicyOutput( +export function transformOutputToFullPolicyOutput( output: Output, standalone = false ): FullAgentPolicyOutput { // eslint-disable-next-line @typescript-eslint/naming-convention - const { config_yaml, type, hosts, ca_sha256, api_key } = output; + const { config_yaml, type, hosts, ca_sha256, ca_trusted_fingerprint, api_key } = output; const configJs = config_yaml ? safeLoad(config_yaml) : {}; const newOutput: FullAgentPolicyOutput = { + ...configJs, type, hosts, ca_sha256, api_key, - ...configJs, + ...(ca_trusted_fingerprint ? { 'ssl.ca_trusted_fingerprint': ca_trusted_fingerprint } : {}), }; if (standalone) { diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 69859855d74f0..b63f86e0bf81f 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -7,6 +7,7 @@ import { uniq, omit } from 'lodash'; import uuid from 'uuid/v4'; +import uuidv5 from 'uuid/v5'; import type { ElasticsearchClient, SavedObjectsClientContract, @@ -33,7 +34,12 @@ import type { ListWithKuery, NewPackagePolicy, } from '../types'; -import { agentPolicyStatuses, packageToPackagePolicy, AGENT_POLICY_INDEX } from '../../common'; +import { + agentPolicyStatuses, + packageToPackagePolicy, + AGENT_POLICY_INDEX, + UUID_V5_NAMESPACE, +} from '../../common'; import type { DeleteAgentPolicyResponse, FleetServerPolicy, @@ -57,6 +63,7 @@ import { agentPolicyUpdateEventHandler } from './agent_policy_update'; import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object'; import { appContextService } from './app_context'; import { getFullAgentPolicy } from './agent_policies'; + const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; class AgentPolicyService { @@ -127,14 +134,11 @@ class AgentPolicyService { }; let searchParams; - if (id) { - searchParams = { - id: String(id), - }; - } else if ( - preconfiguredAgentPolicy.is_default || - preconfiguredAgentPolicy.is_default_fleet_server - ) { + + const isDefaultPolicy = + preconfiguredAgentPolicy.is_default || preconfiguredAgentPolicy.is_default_fleet_server; + + if (isDefaultPolicy) { searchParams = { searchFields: [ preconfiguredAgentPolicy.is_default_fleet_server @@ -143,10 +147,15 @@ class AgentPolicyService { ], search: 'true', }; + } else if (id) { + searchParams = { + id: String(id), + }; } + if (!searchParams) throw new Error('Missing ID'); - return await this.ensureAgentPolicy(soClient, esClient, newAgentPolicy, searchParams); + return await this.ensureAgentPolicy(soClient, esClient, newAgentPolicy, searchParams, id); } private async ensureAgentPolicy( @@ -158,7 +167,8 @@ class AgentPolicyService { | { searchFields: string[]; search: string; - } + }, + id?: string | number ): Promise<{ created: boolean; policy: AgentPolicy; @@ -196,7 +206,7 @@ class AgentPolicyService { if (agentPolicies.total === 0) { return { created: true, - policy: await this.create(soClient, esClient, newAgentPolicy), + policy: await this.create(soClient, esClient, newAgentPolicy, { id: String(id) }), }; } @@ -780,6 +790,7 @@ export async function addPackageToAgentPolicy( agentPolicy: AgentPolicy, defaultOutput: Output, packagePolicyName?: string, + packagePolicyId?: string | number, packagePolicyDescription?: string, transformPackagePolicy?: (p: NewPackagePolicy) => NewPackagePolicy, bumpAgentPolicyRevison = false @@ -803,7 +814,14 @@ export async function addPackageToAgentPolicy( ? transformPackagePolicy(basePackagePolicy) : basePackagePolicy; + // If an ID is provided via preconfiguration, use that value. Otherwise fall back to + // a UUID v5 value seeded from the agent policy's ID and the provided package policy name. + const id = packagePolicyId + ? String(packagePolicyId) + : uuidv5(`${agentPolicy.id}-${packagePolicyName}`, UUID_V5_NAMESPACE); + await packagePolicyService.create(soClient, esClient, newPackagePolicy, { + id, bumpRevision: bumpAgentPolicyRevison, skipEnsureInstalled: true, skipUniqueNameVerification: true, diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index eb5b43650dad7..4224ff6b01a19 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -291,7 +291,6 @@ async function installDataStreamComponentTemplates(params: { }); const templateNames = Object.keys(templates); const templateEntries = Object.entries(templates); - // TODO: Check return values for errors await Promise.all( templateEntries.map(async ([name, body]) => { @@ -307,7 +306,6 @@ async function installDataStreamComponentTemplates(params: { const { clusterPromise } = putComponentTemplate(esClient, logger, { body, name, - create: true, }); return clusterPromise; } @@ -343,8 +341,7 @@ export async function ensureDefaultComponentTemplate( await putComponentTemplate(esClient, logger, { name: FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, body: FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, - create: true, - }); + }).clusterPromise; } return { isCreated: !existingTemplate }; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 4c10d0e74dad7..64091e16d2cef 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -381,6 +381,40 @@ describe('EPM template', () => { expect(mappings).toEqual(keywordWithMultiFieldsMapping); }); + it('tests processing wildcard field with multi fields', () => { + const keywordWithMultiFieldsLiteralYml = ` +- name: keywordWithMultiFields + type: wildcard + multi_fields: + - name: raw + type: keyword + - name: indexed + type: text +`; + + const keywordWithMultiFieldsMapping = { + properties: { + keywordWithMultiFields: { + ignore_above: 1024, + type: 'wildcard', + fields: { + raw: { + ignore_above: 1024, + type: 'keyword', + }, + indexed: { + type: 'text', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(keywordWithMultiFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(keywordWithMultiFieldsMapping); + }); + it('tests processing object field with no other attributes', () => { const objectFieldLiteralYml = ` - name: objectField diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 05f7b744f4db9..4c223c348fe42 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -146,6 +146,13 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings { fieldProps.fields = generateMultiFields(field.multi_fields); } break; + case 'wildcard': + const wildcardMapping = generateWildcardMapping(field); + fieldProps = { ...fieldProps, ...wildcardMapping, type: 'wildcard' }; + if (field.multi_fields) { + fieldProps.fields = generateMultiFields(field.multi_fields); + } + break; case 'constant_keyword': fieldProps.type = field.type; if (field.value) { @@ -270,6 +277,19 @@ function generateTextMapping(field: Field): IndexTemplateMapping { return mapping; } +function generateWildcardMapping(field: Field): IndexTemplateMapping { + const mapping: IndexTemplateMapping = { + ignore_above: DEFAULT_IGNORE_ABOVE, + }; + if (field.null_value) { + mapping.null_value = field.null_value; + } + if (field.ignore_above) { + mapping.ignore_above = field.ignore_above; + } + return mapping; +} + function getDefaultProperties(field: Field): Properties { const properties: Properties = {}; diff --git a/x-pack/plugins/fleet/server/services/epm/fields/field.ts b/x-pack/plugins/fleet/server/services/epm/fields/field.ts index 9fa738aa60d09..d854a0fe8e74d 100644 --- a/x-pack/plugins/fleet/server/services/epm/fields/field.ts +++ b/x-pack/plugins/fleet/server/services/epm/fields/field.ts @@ -34,6 +34,7 @@ export interface Field { dynamic?: 'strict' | boolean; include_in_parent?: boolean; include_in_root?: boolean; + null_value?: string; // Meta fields metric_type?: string; 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 a580248b43731..77fcc429b2084 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -164,7 +164,7 @@ export async function handleInstallPackageFailure({ const installType = getInstallType({ pkgVersion, installedPkg }); if (installType === 'install' || installType === 'reinstall') { logger.error(`uninstalling ${pkgkey} after error installing: [${error.toString()}]`); - await removeInstallation({ savedObjectsClient, pkgkey, esClient }); + await removeInstallation({ savedObjectsClient, pkgName, pkgVersion, esClient }); } await updateInstallStatus({ savedObjectsClient, pkgName, status: 'install_failed' }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 957dac8c1aacb..848d17f78c929 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -22,7 +22,6 @@ import { removeUnusedIndexPatterns } from '../kibana/index_pattern/install'; import { deleteTransforms } from '../elasticsearch/transform/remove'; import { deleteMlModel } from '../elasticsearch/ml_model'; import { packagePolicyService, appContextService } from '../..'; -import { splitPkgKey } from '../registry'; import { deletePackageCache } from '../archive'; import { deleteIlms } from '../elasticsearch/datastream_ilm/remove'; import { removeArchiveEntries } from '../archive/storage'; @@ -31,13 +30,12 @@ import { getInstallation, kibanaSavedObjectTypes } from './index'; export async function removeInstallation(options: { savedObjectsClient: SavedObjectsClientContract; - pkgkey: string; + pkgName: string; + pkgVersion: string; esClient: ElasticsearchClient; force?: boolean; }): Promise { - const { savedObjectsClient, pkgkey, esClient, force } = options; - // TODO: the epm api should change to /name/version so we don't need to do this - const { pkgName, pkgVersion } = splitPkgKey(pkgkey); + const { savedObjectsClient, pkgName, pkgVersion, esClient, force } = options; const installation = await getInstallation({ savedObjectsClient, pkgName }); if (!installation) throw Boom.badRequest(`${pkgName} is not installed`); if (installation.removable === false && !force) diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index 1b6e28a07f8e0..8cfb2844159bc 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -8,9 +8,11 @@ import { URL } from 'url'; import mime from 'mime-types'; -import semverValid from 'semver/functions/valid'; + import type { Response } from 'node-fetch'; +import { splitPkgKey as split } from '../../../../common'; + import { KibanaAssetType } from '../../../types'; import type { AssetsGroupedByServiceByType, @@ -31,12 +33,7 @@ import { } from '../archive'; import { streamToBuffer } from '../streams'; import { appContextService } from '../..'; -import { - PackageKeyInvalidError, - PackageNotFoundError, - PackageCacheError, - RegistryResponseError, -} from '../../../errors'; +import { PackageNotFoundError, PackageCacheError, RegistryResponseError } from '../../../errors'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { getRegistryUrl } from './registry_url'; @@ -46,33 +43,7 @@ export interface SearchParams { experimental?: boolean; } -/** - * Extract the package name and package version from a string. - * - * @param pkgkey a string containing the package name delimited by the package version - */ -export function splitPkgKey(pkgkey: string): { pkgName: string; pkgVersion: string } { - // If no version is provided, use the provided package key as the - // package name and return an empty version value - if (!pkgkey.includes('-')) { - return { pkgName: pkgkey, pkgVersion: '' }; - } - - const pkgName = pkgkey.includes('-') ? pkgkey.substr(0, pkgkey.indexOf('-')) : pkgkey; - - if (pkgName === '') { - throw new PackageKeyInvalidError('Package key parsing failed: package name was empty'); - } - - // this will return the entire string if `indexOf` return -1 - const pkgVersion = pkgkey.substr(pkgkey.indexOf('-') + 1); - if (!semverValid(pkgVersion)) { - throw new PackageKeyInvalidError( - 'Package key parsing failed: package version was not a valid semver' - ); - } - return { pkgName, pkgVersion }; -} +export const splitPkgKey = split; export const pkgToPkgKey = ({ name, version }: { name: string; version: string }) => `${name}-${version}`; diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 4b87c0957c961..8324079e10da8 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -446,6 +446,7 @@ describe('policy preconfiguration', () => { id: 'test-id', package_policies: [ { + id: 'test-package', package: { name: 'test_package' }, name: 'Test package', }, diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 76fa7778eafa2..a41c7606287ee 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -61,6 +61,7 @@ function isPreconfiguredOutputDifferentFromCurrent( preconfiguredOutput.hosts.map(normalizeHostsForAgents) )) || existingOutput.ca_sha256 !== preconfiguredOutput.ca_sha256 || + existingOutput.ca_trusted_fingerprint !== preconfiguredOutput.ca_trusted_fingerprint || existingOutput.config_yaml !== preconfiguredOutput.config_yaml ); } @@ -404,6 +405,7 @@ async function addPreconfiguredPolicyPackages( agentPolicy: AgentPolicy, installedPackagePolicies: Array< Partial> & { + id?: string | number; name: string; installedPackage: Installation; inputs?: InputsOverride[]; @@ -413,7 +415,7 @@ async function addPreconfiguredPolicyPackages( bumpAgentPolicyRevison = false ) { // Add packages synchronously to avoid overwriting - for (const { installedPackage, name, description, inputs } of installedPackagePolicies) { + for (const { installedPackage, id, name, description, inputs } of installedPackagePolicies) { const packageInfo = await getPackageInfo({ savedObjectsClient: soClient, pkgName: installedPackage.name, @@ -427,6 +429,7 @@ async function addPreconfiguredPolicyPackages( agentPolicy, defaultOutput, name, + id, description, (policy) => preconfigurePackageInputs(policy, packageInfo, inputs), bumpAgentPolicyRevison diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 26d581f32d9a2..0e7b7c5e7a093 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -11,6 +11,7 @@ import type { SavedObjectsClientContract } from 'kibana/server'; import { decodeCloudId, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + GLOBAL_SETTINGS_ID, normalizeHostsForAgents, } from '../../common'; import type { SettingsSOAttributes, Settings, BaseSettings } from '../../common'; @@ -80,10 +81,17 @@ export async function saveSettings( } catch (e) { if (e.isBoom && e.output.statusCode === 404) { const defaultSettings = createDefaultSettings(); - const res = await soClient.create(GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, { - ...defaultSettings, - ...data, - }); + const res = await soClient.create( + GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + { + ...defaultSettings, + ...data, + }, + { + id: GLOBAL_SETTINGS_ID, + overwrite: true, + } + ); return { id: res.id, diff --git a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts index 3ba89f1e526b3..4d030e1e87ed4 100644 --- a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts @@ -87,6 +87,7 @@ export const PreconfiguredOutputsSchema = schema.arrayOf( type: schema.oneOf([schema.literal(outputType.Elasticsearch)]), hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), ca_sha256: schema.maybe(schema.string()), + ca_trusted_fingerprint: schema.maybe(schema.string()), config: schema.maybe(schema.object({}, { unknowns: 'allow' })), }), { @@ -106,6 +107,7 @@ export const PreconfiguredAgentPoliciesSchema = schema.arrayOf( monitoring_output_id: schema.maybe(schema.string()), package_policies: schema.arrayOf( schema.object({ + id: schema.maybe(schema.oneOf([schema.string(), schema.number()])), name: schema.string(), package: schema.object({ name: schema.string(), 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 918def62a9d0e..390d5dea792cb 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts @@ -30,12 +30,29 @@ export const GetFileRequestSchema = { }; export const GetInfoRequestSchema = { + params: schema.object({ + pkgName: schema.string(), + pkgVersion: schema.maybe(schema.string()), + }), +}; + +export const GetInfoRequestSchemaDeprecated = { params: schema.object({ pkgkey: schema.string(), }), }; export const UpdatePackageRequestSchema = { + params: schema.object({ + pkgName: schema.string(), + pkgVersion: schema.maybe(schema.string()), + }), + body: schema.object({ + keepPoliciesUpToDate: schema.boolean(), + }), +}; + +export const UpdatePackageRequestSchemaDeprecated = { params: schema.object({ pkgkey: schema.string(), }), @@ -51,6 +68,18 @@ export const GetStatsRequestSchema = { }; export const InstallPackageFromRegistryRequestSchema = { + params: schema.object({ + pkgName: schema.string(), + pkgVersion: schema.maybe(schema.string()), + }), + body: schema.nullable( + schema.object({ + force: schema.boolean(), + }) + ), +}; + +export const InstallPackageFromRegistryRequestSchemaDeprecated = { params: schema.object({ pkgkey: schema.string(), }), @@ -72,6 +101,18 @@ export const InstallPackageByUploadRequestSchema = { }; export const DeletePackageRequestSchema = { + params: schema.object({ + pkgName: schema.string(), + pkgVersion: schema.string(), + }), + body: schema.nullable( + schema.object({ + force: schema.boolean(), + }) + ), +}; + +export const DeletePackageRequestSchemaDeprecated = { params: schema.object({ pkgkey: schema.string(), }), diff --git a/x-pack/plugins/fleet/server/types/rest_spec/output.ts b/x-pack/plugins/fleet/server/types/rest_spec/output.ts index dc60b26087219..de2ddeb3a1bfd 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/output.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/output.ts @@ -30,6 +30,7 @@ export const PostOutputRequestSchema = { is_default_monitoring: schema.boolean({ defaultValue: false }), hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), ca_sha256: schema.maybe(schema.string()), + ca_trusted_fingerprint: schema.maybe(schema.string()), config_yaml: schema.maybe(schema.string()), }), }; @@ -45,6 +46,7 @@ export const PutOutputRequestSchema = { is_default_monitoring: schema.maybe(schema.boolean()), hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), ca_sha256: schema.maybe(schema.string()), + ca_trusted_fingerprint: schema.maybe(schema.string()), config_yaml: schema.maybe(schema.string()), }), }; diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/categories.ts b/x-pack/plugins/fleet/storybook/context/fixtures/categories.ts index 002748bd3d967..1bb8d3300624e 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/categories.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/categories.ts @@ -6,7 +6,7 @@ */ import type { GetCategoriesResponse } from '../../../public/types'; -export const response: GetCategoriesResponse['response'] = [ +export const items: GetCategoriesResponse['items'] = [ { id: 'aws', title: 'AWS', diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts index de4fd228b5342..6f48b15158f8d 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts @@ -7,7 +7,7 @@ import type { GetInfoResponse } from '../../../public/types'; import { KibanaAssetType, ElasticsearchAssetType } from '../../../common/types'; -export const response: GetInfoResponse['response'] = { +export const item: GetInfoResponse['item'] = { name: 'nginx', title: 'Nginx', version: '0.7.0', diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts index 360c340c9645f..6b766c2d126df 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts @@ -7,7 +7,7 @@ import type { GetInfoResponse } from '../../../public/types'; import { KibanaAssetType, ElasticsearchAssetType } from '../../../common/types'; -export const response: GetInfoResponse['response'] = { +export const item: GetInfoResponse['item'] = { name: 'okta', title: 'Okta', version: '1.2.0', diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/packages.ts b/x-pack/plugins/fleet/storybook/context/fixtures/packages.ts index dfe8e905be089..4c13b6b6bf8cb 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/packages.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/packages.ts @@ -7,7 +7,7 @@ import type { GetPackagesResponse } from '../../../public/types'; -export const response: GetPackagesResponse['response'] = [ +export const items: GetPackagesResponse['items'] = [ { name: 'ga_not_installed', title: 'a. GA, Not Installed', diff --git a/x-pack/plugins/fleet/storybook/context/http.ts b/x-pack/plugins/fleet/storybook/context/http.ts index 3e515c075a595..491b62201e532 100644 --- a/x-pack/plugins/fleet/storybook/context/http.ts +++ b/x-pack/plugins/fleet/storybook/context/http.ts @@ -55,7 +55,7 @@ export const getHttp = (basepath = BASE_PATH) => { // Ideally, this would be a markdown file instead of a ts file, but we don't have // markdown-loader in our package.json, so we'll make do with what we have. - if (path.startsWith('/api/fleet/epm/packages/nginx/')) { + if (path.match('/api/fleet/epm/packages/nginx/.*/.*/')) { const { readme } = await import('./fixtures/readme.nginx'); return readme; } @@ -66,7 +66,7 @@ export const getHttp = (basepath = BASE_PATH) => { // Ideally, this would be a markdown file instead of a ts file, but we don't have // markdown-loader in our package.json, so we'll make do with what we have. - if (path.startsWith('/api/fleet/epm/packages/okta/')) { + if (path.match('/api/fleet/epm/packages/okta/.*/.*/')) { const { readme } = await import('./fixtures/readme.okta'); return readme; } diff --git a/x-pack/plugins/grokdebugger/public/plugin.js b/x-pack/plugins/grokdebugger/public/plugin.js index 63ebbb761b88d..b92cfce6ec0ef 100644 --- a/x-pack/plugins/grokdebugger/public/plugin.js +++ b/x-pack/plugins/grokdebugger/public/plugin.js @@ -22,11 +22,11 @@ export class GrokDebuggerUIPlugin { }), id: PLUGIN.ID, enableRouting: false, - async mount({ element }) { + async mount({ element, theme$ }) { const [coreStart] = await coreSetup.getStartServices(); const license = await plugins.licensing.license$.pipe(first()).toPromise(); const { renderApp } = await import('./render_app'); - return renderApp(license, element, coreStart); + return renderApp(license, element, coreStart, theme$); }, }); diff --git a/x-pack/plugins/grokdebugger/public/render_app.js b/x-pack/plugins/grokdebugger/public/render_app.js index 9666d69d978f0..bcb2560a3c0b7 100644 --- a/x-pack/plugins/grokdebugger/public/render_app.js +++ b/x-pack/plugins/grokdebugger/public/render_app.js @@ -7,23 +7,27 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n-react'; +import { KibanaContextProvider, KibanaThemeProvider } from './shared_imports'; import { GrokDebugger } from './components/grok_debugger'; import { GrokdebuggerService } from './services/grokdebugger/grokdebugger_service'; -import { I18nProvider } from '@kbn/i18n-react'; -import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; import { InactiveLicenseSlate } from './components/inactive_license'; -export function renderApp(license, element, coreStart) { +export function renderApp(license, element, coreStart, theme$) { const content = license.isActive ? ( - + + + ) : ( - + + + ); diff --git a/x-pack/plugins/grokdebugger/public/shared_imports.ts b/x-pack/plugins/grokdebugger/public/shared_imports.ts index cab31cb683786..2779673d665b9 100644 --- a/x-pack/plugins/grokdebugger/public/shared_imports.ts +++ b/x-pack/plugins/grokdebugger/public/shared_imports.ts @@ -6,3 +6,8 @@ */ export { EuiCodeEditor } from '../../../../src/plugins/es_ui_shared/public'; + +export { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap index 8f375305d359e..f4d7fc149a694 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap @@ -42,57 +42,70 @@ exports[`policy table shows empty state when there are no policies 1`] = ` role="main" >
-
-

- Create your first index lifecycle policy -

- -
-

- An index lifecycle policy helps you manage your indices as they age. -

-
- -
- +

+ Create your first index lifecycle policy +

+ +
+
+

+ An index lifecycle policy helps you manage your indices as they age. +

+
+ +
+ +
+
+
`; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx index 5a6d8bb878c37..933a2fd28e07f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx @@ -7,17 +7,25 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { I18nStart, ScopedHistory, ApplicationStart } from 'kibana/public'; -import { UnmountCallback } from 'src/core/public'; -import { CloudSetup } from '../../../cloud/public'; -import { ILicense } from '../../../licensing/public'; - -import { KibanaContextProvider, APP_WRAPPER_CLASS } from '../shared_imports'; +import { Observable } from 'rxjs'; +import { + I18nStart, + ScopedHistory, + ApplicationStart, + UnmountCallback, + CoreTheme, +} from 'src/core/public'; +import { + CloudSetup, + ILicense, + KibanaContextProvider, + APP_WRAPPER_CLASS, + RedirectAppLinks, + KibanaThemeProvider, +} from '../shared_imports'; import { App } from './app'; - import { BreadcrumbService } from './services/breadcrumbs'; -import { RedirectAppLinks } from '../../../../../src/plugins/kibana_react/public'; export const renderApp = ( element: Element, @@ -26,15 +34,18 @@ export const renderApp = ( application: ApplicationStart, breadcrumbService: BreadcrumbService, license: ILicense, + theme$: Observable, cloud?: CloudSetup ): UnmountCallback => { const { getUrlForApp } = application; render( - - - + + + + + , element diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx index bc0981529c34f..d59fd4f20e63f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -50,7 +50,7 @@ export class IndexLifecycleManagementPlugin id: PLUGIN.ID, title: PLUGIN.TITLE, order: 2, - mount: async ({ element, history, setBreadcrumbs }) => { + mount: async ({ element, history, setBreadcrumbs, theme$ }) => { const [coreStart, { licensing }] = await getStartServices(); const { chrome: { docTitle }, @@ -78,6 +78,7 @@ export class IndexLifecycleManagementPlugin application, this.breadcrumbService, license, + theme$, cloud ); diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index dab299c476eea..dcf435fd72831 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -20,6 +20,7 @@ export type { ValidationConfig, ValidationError, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; + export { useForm, useFormData, @@ -43,8 +44,16 @@ export { export { attemptToURIDecode } from '../../../../src/plugins/es_ui_shared/public'; -export { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; +export { + KibanaContextProvider, + KibanaThemeProvider, + RedirectAppLinks, +} from '../../../../src/plugins/kibana_react/public'; export { APP_WRAPPER_CLASS } from '../../../../src/core/public'; export const useKibana = () => _useKibana(); + +export type { CloudSetup } from '../../cloud/public'; + +export type { ILicense } from '../../licensing/public'; 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 ac4b4c46ad4d1..5da1fc61742e6 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,7 +25,6 @@ export type TestSubjects = | 'ilmPolicyLink' | 'includeStatsSwitch' | 'includeManagedSwitch' - | 'indexActionsContextMenuButton' | 'indexContextMenu' | 'indexManagementHeaderContent' | 'indexTable' diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index 0e4564163c553..c1b8dfcc0034f 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -45,7 +45,6 @@ export const setup = async (overridingDependencies: any = {}): Promise { const { find, component } = testBed; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts index ec80bf5d712c0..689c48b24a9c3 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts @@ -196,11 +196,22 @@ describe('', () => { httpRequestsMockHelpers.setReloadIndicesResponse({ indexNames: [indexName] }); testBed = await setup(); - const { find, component } = testBed; + const { component, find } = testBed; + component.update(); find('indexTableIndexNameLink').at(0).simulate('click'); }); + test('should be able to close an open index', async () => { + const { actions } = testBed; + + await actions.clickManageContextMenuButton(); + await actions.clickContextMenuOption('closeIndexMenuButton'); + + // A refresh call was added after closing an index so we need to check the second to last request. + const latestRequest = server.requests[server.requests.length - 2]; + expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/close`); + }); test('should be able to flush index', async () => { const { actions } = testBed; diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx index 5cd0864a4df21..f44ff13b205db 100644 --- a/x-pack/plugins/index_management/public/application/app_context.tsx +++ b/x-pack/plugins/index_management/public/application/app_context.tsx @@ -6,15 +6,15 @@ */ import React, { createContext, useContext } from 'react'; -import { ScopedHistory } from 'kibana/public'; +import { Observable } from 'rxjs'; import SemVer from 'semver/classes/semver'; import { ManagementAppMountParams } from 'src/plugins/management/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import { CoreSetup, CoreStart, CoreTheme, ScopedHistory } from 'src/core/public'; +import { SharePluginStart } from 'src/plugins/share/public'; -import { CoreSetup, CoreStart } from '../../../../../src/core/public'; -import { UiMetricService, NotificationService, HttpService } from './services'; import { ExtensionsService } from '../services'; -import { SharePluginStart } from '../../../../../src/plugins/share/public'; +import { UiMetricService, NotificationService, HttpService } from './services'; const AppContext = createContext(undefined); @@ -39,6 +39,7 @@ export interface AppDependencies { url: SharePluginStart['url']; docLinks: CoreStart['docLinks']; kibanaVersion: SemVer; + theme$: Observable; } export const AppContextProvider = ({ diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index a39baf59d1f05..1f4abac806276 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -74,13 +74,15 @@ describe('', () => { expect(nameInput.props().disabled).toEqual(true); }); - // FLAKY: https://github.com/elastic/kibana/issues/84906 - describe.skip('form payload', () => { + describe('form payload', () => { it('should send the correct payload with changed values', async () => { const { actions, component, form } = testBed; await act(async () => { form.setInputValue('versionField.input', '1'); + }); + + await act(async () => { actions.clickNextButton(); }); diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx index 854826681adae..409bd7443532d 100644 --- a/x-pack/plugins/index_management/public/application/index.tsx +++ b/x-pack/plugins/index_management/public/application/index.tsx @@ -17,6 +17,7 @@ import { createKibanaReactContext, GlobalFlyout, useKibana as useKibanaReactPlugin, + KibanaThemeProvider, } from '../shared_imports'; import { AppContextProvider, AppDependencies } from './app_context'; @@ -36,7 +37,7 @@ export const renderApp = ( const { i18n, docLinks, notifications, application } = core; const { Context: I18nContext } = i18n; - const { services, history, setBreadcrumbs, uiSettings, kibanaVersion } = dependencies; + const { services, history, setBreadcrumbs, uiSettings, kibanaVersion, theme$ } = dependencies; // uiSettings is required by the CodeEditor component used to edit runtime field Painless scripts. const { Provider: KibanaReactContextProvider } = @@ -59,19 +60,21 @@ export const renderApp = ( render( - - - - - - - - - - - - - + + + + + + + + + + + + + + + , elem ); diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index 48508695bfc98..cd9d2de55ff0e 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -54,7 +54,7 @@ export async function mountManagementSection( isFleetEnabled: boolean, kibanaVersion: SemVer ) { - const { element, setBreadcrumbs, history } = params; + const { element, setBreadcrumbs, history, theme$ } = params; const [core, startDependencies] = await coreSetup.getStartServices(); const { docLinks, @@ -91,6 +91,7 @@ export async function mountManagementSection( url, docLinks, kibanaVersion, + theme$, }; const unmountAppCallback = renderApp(element, { core, dependencies: appDependencies }); diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index 7fadcd9e71502..c2d76a50fa1ac 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -14,6 +14,7 @@ export type { UseRequestResponse, Error, } from '../../../../src/plugins/es_ui_shared/public'; + export { sendRequest, useRequest, @@ -31,6 +32,7 @@ export type { FormSchema, FieldConfig, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; + export { FIELD_TYPES, VALIDATION_TYPES, @@ -61,4 +63,5 @@ export { createKibanaReactContext, reactRouterNavigate, useKibana, + KibanaThemeProvider, } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/infra/common/alerting/logs/log_threshold/types.ts b/x-pack/plugins/infra/common/alerting/logs/log_threshold/types.ts index 6da0bb58e4e85..dba94d2c8fd93 100644 --- a/x-pack/plugins/infra/common/alerting/logs/log_threshold/types.ts +++ b/x-pack/plugins/infra/common/alerting/logs/log_threshold/types.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import * as rt from 'io-ts'; import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; -export const LOG_DOCUMENT_COUNT_ALERT_TYPE_ID = 'logs.alert.document.count'; +export const LOG_DOCUMENT_COUNT_RULE_TYPE_ID = 'logs.alert.document.count'; const ThresholdTypeRT = rt.keyof({ count: null, @@ -143,7 +143,7 @@ export type TimeUnit = rt.TypeOf; export const timeSizeRT = rt.number; export const groupByRT = rt.array(rt.string); -const RequiredAlertParamsRT = rt.type({ +const RequiredRuleParamsRT = rt.type({ // NOTE: "count" would be better named as "threshold", but this would require a // migration of encrypted saved objects, so we'll keep "count" until it's problematic. count: ThresholdRT, @@ -151,72 +151,69 @@ const RequiredAlertParamsRT = rt.type({ timeSize: timeSizeRT, }); -const partialRequiredAlertParamsRT = rt.partial(RequiredAlertParamsRT.props); -export type PartialRequiredAlertParams = rt.TypeOf; +const partialRequiredRuleParamsRT = rt.partial(RequiredRuleParamsRT.props); +export type PartialRequiredRuleParams = rt.TypeOf; -const OptionalAlertParamsRT = rt.partial({ +const OptionalRuleParamsRT = rt.partial({ groupBy: groupByRT, }); -export const countAlertParamsRT = rt.intersection([ +export const countRuleParamsRT = rt.intersection([ rt.type({ criteria: countCriteriaRT, - ...RequiredAlertParamsRT.props, + ...RequiredRuleParamsRT.props, }), rt.partial({ - ...OptionalAlertParamsRT.props, + ...OptionalRuleParamsRT.props, }), ]); -export type CountAlertParams = rt.TypeOf; +export type CountRuleParams = rt.TypeOf; -export const partialCountAlertParamsRT = rt.intersection([ +export const partialCountRuleParamsRT = rt.intersection([ rt.type({ criteria: partialCountCriteriaRT, - ...RequiredAlertParamsRT.props, + ...RequiredRuleParamsRT.props, }), rt.partial({ - ...OptionalAlertParamsRT.props, + ...OptionalRuleParamsRT.props, }), ]); -export type PartialCountAlertParams = rt.TypeOf; +export type PartialCountRuleParams = rt.TypeOf; -export const ratioAlertParamsRT = rt.intersection([ +export const ratioRuleParamsRT = rt.intersection([ rt.type({ criteria: ratioCriteriaRT, - ...RequiredAlertParamsRT.props, + ...RequiredRuleParamsRT.props, }), rt.partial({ - ...OptionalAlertParamsRT.props, + ...OptionalRuleParamsRT.props, }), ]); -export type RatioAlertParams = rt.TypeOf; +export type RatioRuleParams = rt.TypeOf; -export const partialRatioAlertParamsRT = rt.intersection([ +export const partialRatioRuleParamsRT = rt.intersection([ rt.type({ criteria: partialRatioCriteriaRT, - ...RequiredAlertParamsRT.props, + ...RequiredRuleParamsRT.props, }), rt.partial({ - ...OptionalAlertParamsRT.props, + ...OptionalRuleParamsRT.props, }), ]); -export type PartialRatioAlertParams = rt.TypeOf; +export type PartialRatioRuleParams = rt.TypeOf; -export const alertParamsRT = rt.union([countAlertParamsRT, ratioAlertParamsRT]); -export type AlertParams = rt.TypeOf; +export const ruleParamsRT = rt.union([countRuleParamsRT, ratioRuleParamsRT]); +export type RuleParams = rt.TypeOf; -export const partialAlertParamsRT = rt.union([ - partialCountAlertParamsRT, - partialRatioAlertParamsRT, -]); -export type PartialAlertParams = rt.TypeOf; +export const partialRuleParamsRT = rt.union([partialCountRuleParamsRT, partialRatioRuleParamsRT]); +export type PartialRuleParams = rt.TypeOf; -export const isRatioAlert = (criteria: PartialCriteria): criteria is PartialRatioCriteria => { +export const isRatioRule = (criteria: PartialCriteria): criteria is PartialRatioCriteria => { return criteria.length > 0 && Array.isArray(criteria[0]) ? true : false; }; -export const isRatioAlertParams = (params: AlertParams): params is RatioAlertParams => { - return isRatioAlert(params.criteria); +export const isRatioRuleParams = (params: RuleParams): params is RatioRuleParams => { + return isRatioRule(params.criteria); }; export const getNumerator = (criteria: C): C[0] => { @@ -229,8 +226,8 @@ export const getDenominator = ( return criteria[1]; }; -export const hasGroupBy = (alertParams: AlertParams) => { - const { groupBy } = alertParams; +export const hasGroupBy = (params: RuleParams) => { + const { groupBy } = params; return groupBy && groupBy.length > 0 ? true : false; }; @@ -339,8 +336,8 @@ export const isOptimizedGroupedSearchQueryResponse = ( }; export const isOptimizableGroupedThreshold = ( - selectedComparator: AlertParams['count']['comparator'], - selectedValue?: AlertParams['count']['value'] + selectedComparator: RuleParams['count']['comparator'], + selectedValue?: RuleParams['count']['value'] ) => { if (selectedComparator === Comparator.GT) { return true; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_flyout.tsx index 346b44218b612..bee7f93a538be 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_flyout.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useContext, useMemo } from 'react'; import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; -import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../../../../common/alerting/logs/log_threshold/types'; +import { LOG_DOCUMENT_COUNT_RULE_TYPE_ID } from '../../../../common/alerting/logs/log_threshold/types'; interface Props { visible?: boolean; @@ -25,7 +25,7 @@ export const AlertFlyout = (props: Props) => { consumer: 'logs', onClose: onCloseFlyout, canChangeTrigger: false, - alertTypeId: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + alertTypeId: LOG_DOCUMENT_COUNT_RULE_TYPE_ID, metadata: { isInternal: true, }, diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criteria.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criteria.tsx index 9ff9b602fac3b..1be120210984f 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criteria.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criteria.tsx @@ -12,12 +12,12 @@ import { i18n } from '@kbn/i18n'; import { IFieldType } from 'src/plugins/data/public'; import { Criterion } from './criterion'; import { - PartialAlertParams, + PartialRuleParams, PartialCountCriteria as PartialCountCriteriaType, PartialCriteria as PartialCriteriaType, PartialCriterion as PartialCriterionType, PartialRatioCriteria as PartialRatioCriteriaType, - isRatioAlert, + isRatioRule, getNumerator, getDenominator, } from '../../../../../common/alerting/logs/log_threshold/types'; @@ -38,7 +38,7 @@ interface SharedProps { criteria?: PartialCriteriaType; defaultCriterion: PartialCriterionType; errors: Errors['criteria']; - alertParams: PartialAlertParams; + ruleParams: PartialRuleParams; sourceId: string; updateCriteria: (criteria: PartialCriteriaType) => void; } @@ -49,7 +49,7 @@ export const Criteria: React.FC = (props) => { const { criteria, errors } = props; if (!criteria || criteria.length === 0) return null; - return !isRatioAlert(criteria) ? ( + return !isRatioRule(criteria) ? ( ) : ( @@ -57,7 +57,7 @@ export const Criteria: React.FC = (props) => { }; interface CriteriaWrapperProps { - alertParams: SharedProps['alertParams']; + ruleParams: SharedProps['ruleParams']; fields: SharedProps['fields']; updateCriterion: (idx: number, params: PartialCriterionType) => void; removeCriterion: (idx: number) => void; @@ -76,7 +76,7 @@ const CriteriaWrapper: React.FC = (props) => { criteria, fields, errors, - alertParams, + ruleParams, sourceId, isRatio = false, } = props; @@ -103,7 +103,7 @@ const CriteriaWrapper: React.FC = (props) => { arrowDisplay="right" > ; sourceId: string; showThreshold: boolean; } export const CriterionPreview: React.FC = ({ - alertParams, + ruleParams, chartCriterion, sourceId, showThreshold, @@ -69,12 +69,12 @@ export const CriterionPreview: React.FC = ({ const params = { criteria, count: { - comparator: alertParams.count.comparator, - value: alertParams.count.value, + comparator: ruleParams.count.comparator, + value: ruleParams.count.value, }, - timeSize: alertParams.timeSize, - timeUnit: alertParams.timeUnit, - groupBy: alertParams.groupBy, + timeSize: ruleParams.timeSize, + timeUnit: ruleParams.timeUnit, + groupBy: ruleParams.groupBy, }; try { @@ -83,11 +83,11 @@ export const CriterionPreview: React.FC = ({ return null; } }, [ - alertParams.timeSize, - alertParams.timeUnit, - alertParams.groupBy, - alertParams.count.comparator, - alertParams.count.value, + ruleParams.timeSize, + ruleParams.timeUnit, + ruleParams.groupBy, + ruleParams.count.comparator, + ruleParams.count.value, chartCriterion, ]); @@ -102,7 +102,7 @@ export const CriterionPreview: React.FC = ({ : NUM_BUCKETS / 4 } // Display less data for groups due to space limitations sourceId={sourceId} - threshold={alertParams.count} + threshold={ruleParams.count} chartAlertParams={chartAlertParams} showThreshold={showThreshold} /> diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/editor.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/editor.tsx index 02b827d5915dd..312662286595c 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/editor.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/editor.tsx @@ -16,11 +16,11 @@ import { } from '../../../../../../triggers_actions_ui/public'; import { Comparator, - isRatioAlert, - PartialAlertParams, - PartialCountAlertParams, + isRatioRule, + PartialRuleParams, + PartialCountRuleParams, PartialCriteria as PartialCriteriaType, - PartialRatioAlertParams, + PartialRatioRuleParams, ThresholdType, timeUnitRT, isOptimizableGroupedThreshold, @@ -64,9 +64,9 @@ const createDefaultCriterion = ( ? { field: DEFAULT_FIELD, comparator: Comparator.EQ, value } : { field: undefined, comparator: undefined, value: undefined }; -const createDefaultCountAlertParams = ( +const createDefaultCountRuleParams = ( availableFields: LogIndexField[] -): PartialCountAlertParams => ({ +): PartialCountRuleParams => ({ ...DEFAULT_BASE_EXPRESSION, count: { value: 75, @@ -75,9 +75,9 @@ const createDefaultCountAlertParams = ( criteria: [createDefaultCriterion(availableFields, 'error')], }); -const createDefaultRatioAlertParams = ( +const createDefaultRatioRuleParams = ( availableFields: LogIndexField[] -): PartialRatioAlertParams => ({ +): PartialRatioRuleParams => ({ ...DEFAULT_BASE_EXPRESSION, count: { value: 2, @@ -90,7 +90,7 @@ const createDefaultRatioAlertParams = ( }); export const ExpressionEditor: React.FC< - AlertTypeParamsExpressionProps + AlertTypeParamsExpressionProps > = (props) => { const isInternal = props.metadata?.isInternal ?? false; const [sourceId] = useSourceId(); @@ -159,7 +159,7 @@ export const SourceStatusWrapper: React.FC = ({ children }) => { ); }; -export const Editor: React.FC> = +export const Editor: React.FC> = (props) => { const { setAlertParams, alertParams, errors } = props; const [hasSetDefaults, setHasSetDefaults] = useState(false); @@ -231,7 +231,7 @@ export const Editor: React.FC createDefaultCountAlertParams(supportedFields), + () => createDefaultCountRuleParams(supportedFields), [supportedFields] ); @@ -240,7 +240,7 @@ export const Editor: React.FC @@ -286,7 +286,7 @@ export const Editor: React.FC - {alertParams.criteria && !isRatioAlert(alertParams.criteria) && criteriaComponent} + {alertParams.criteria && !isRatioRule(alertParams.criteria) && criteriaComponent} - {alertParams.criteria && isRatioAlert(alertParams.criteria) && criteriaComponent} + {alertParams.criteria && isRatioRule(alertParams.criteria) && criteriaComponent} {shouldShowGroupByOptimizationWarning && ( <> diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/threshold.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/threshold.tsx index af4f5dc1c8115..fdc60daceb715 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/threshold.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/threshold.tsx @@ -23,7 +23,7 @@ import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types import { Comparator, ComparatorToi18nMap, - AlertParams, + RuleParams, } from '../../../../../common/alerting/logs/log_threshold/types'; const thresholdPrefix = i18n.translate('xpack.infra.logs.alertFlyout.thresholdPrefix', { @@ -49,7 +49,7 @@ const getComparatorOptions = (): Array<{ interface Props { comparator?: Comparator; value?: number; - updateThreshold: (params: Partial) => void; + updateThreshold: (params: Partial) => void; errors: IErrorObject; } diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/type_switcher.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/type_switcher.tsx index cde97dad20613..46e865d3acbe0 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/type_switcher.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/type_switcher.tsx @@ -11,7 +11,7 @@ import { EuiFlexItem, EuiFlexGroup, EuiPopover, EuiSelect, EuiExpression } from import { PartialCriteria, ThresholdType, - isRatioAlert, + isRatioRule, } from '../../../../../common/alerting/logs/log_threshold/types'; import { ExpressionLike } from './editor'; @@ -51,7 +51,7 @@ interface Props { } const getThresholdType = (criteria: PartialCriteria): ThresholdType => { - return isRatioAlert(criteria) ? 'ratio' : 'count'; + return isRatioRule(criteria) ? 'ratio' : 'count'; }; export const TypeSwitcher: React.FC = ({ criteria, updateType }) => { diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_rule_type.ts b/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_rule_type.ts index b0a8737a994a1..56c28074bd5fb 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_rule_type.ts +++ b/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_rule_type.ts @@ -9,15 +9,15 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { ObservabilityRuleTypeModel } from '../../../../observability/public'; import { - LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, - PartialAlertParams, + LOG_DOCUMENT_COUNT_RULE_TYPE_ID, + PartialRuleParams, } from '../../../common/alerting/logs/log_threshold'; import { formatRuleData } from './rule_data_formatters'; import { validateExpression } from './validation'; -export function createLogThresholdRuleType(): ObservabilityRuleTypeModel { +export function createLogThresholdRuleType(): ObservabilityRuleTypeModel { return { - id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + id: LOG_DOCUMENT_COUNT_RULE_TYPE_ID, description: i18n.translate('xpack.infra.logs.alertFlyout.alertDescription', { defaultMessage: 'Alert when the log aggregation exceeds the threshold.', }), diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/validation.ts b/x-pack/plugins/infra/public/alerting/log_threshold/validation.ts index 41c07ee79344b..740805006785b 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/validation.ts +++ b/x-pack/plugins/infra/public/alerting/log_threshold/validation.ts @@ -11,10 +11,10 @@ import { isNumber, isFinite } from 'lodash'; import { IErrorObject, ValidationResult } from '../../../../triggers_actions_ui/public'; import { PartialCountCriteria, - isRatioAlert, + isRatioRule, getNumerator, getDenominator, - PartialRequiredAlertParams, + PartialRequiredRuleParams, PartialCriteria, } from '../../../common/alerting/logs/log_threshold/types'; @@ -50,7 +50,7 @@ export function validateExpression({ count, criteria, timeSize, -}: PartialRequiredAlertParams & { +}: PartialRequiredRuleParams & { criteria: PartialCriteria; }): ValidationResult { const validationResult = { errors: {} }; @@ -122,7 +122,7 @@ export function validateExpression({ return _errors; }; - if (!isRatioAlert(criteria)) { + if (!isRatioRule(criteria)) { const criteriaErrors = getCriterionErrors(criteria); errors.criteria[0] = criteriaErrors; } else { diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx index 19f7d7f1b4a7a..39e58fb518b02 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx @@ -5,11 +5,12 @@ * 2.0. */ +import { Query, Filter } from '@kbn/es-query'; import { CoreStart } from 'kibana/public'; import React from 'react'; import ReactDOM from 'react-dom'; import { Subscription } from 'rxjs'; -import { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public'; +import { TimeRange } from '../../../../../../src/plugins/data/public'; import { Embeddable, EmbeddableInput, diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx index d061866cf24a8..a503c05011246 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx @@ -5,11 +5,11 @@ * 2.0. */ +import { Query } from '@kbn/es-query'; import { EuiBasicTableColumn, EuiInMemoryTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import * as rt from 'io-ts'; import React, { useMemo } from 'react'; -import { Query } from '../../../../../../../src/plugins/data/public'; import { LogEntryField } from '../../../../common/log_entry'; import { LogEntry } from '../../../../common/search_strategies/log_entries/log_entry'; import { TimeKey } from '../../../../common/time'; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index c985f0e9f0bf1..a8e2005d339a6 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -18,7 +18,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useEffect } from 'react'; -import type { Query } from '../../../../../../../src/plugins/data/public'; +import type { Query } from '@kbn/es-query'; import { TimeKey } from '../../../../common/time'; import { useLogEntry } from '../../../containers/logs/log_entry'; import { CenteredEuiFlyoutBody } from '../../centered_flyout_body'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx index f085a2c7d275b..5a62cb5e38677 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; import React, { useContext } from 'react'; -import { Query } from '../../../../../../../src/plugins/data/public'; +import { Query } from '@kbn/es-query'; import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; import { LogFilterState } from './log_filter_state'; 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 7f82a8841fdbf..a68432472c245 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 @@ -10,7 +10,7 @@ import moment from 'moment'; import { stringify } from 'query-string'; import React, { useCallback, useMemo } from 'react'; import { encode, RisonValue } from 'rison-node'; -import type { Query } from '../../../../../../../src/plugins/data/public'; +import type { Query } from '@kbn/es-query'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useTrackPageview } from '../../../../../observability/public'; import { TimeKey } from '../../../../common/time'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_dataset_filtering.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_dataset_filtering.ts index 9bd1e42779a36..3231a21ac1978 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_dataset_filtering.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_dataset_filtering.ts @@ -5,9 +5,9 @@ * 2.0. */ +import { Filter } from '@kbn/es-query'; import { useEffect, useReducer, useCallback } from 'react'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; -import { Filter } from '../../../../../../../src/plugins/data/common'; import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../../../../../ml/public'; interface ReducerState { diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx index 2af290b816689..c300f045e4830 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx @@ -8,7 +8,7 @@ import { EuiSpacer } from '@elastic/eui'; import React, { useContext, useCallback, useMemo, useEffect } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; -import type { Query } from '../../../../../../../src/plugins/data/public'; +import type { Query } from '@kbn/es-query'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { LogEntry } from '../../../../common/log_entry'; import { TimeKey } from '../../../../common/time'; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 015476687d468..fe036fd613fc9 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -8,7 +8,8 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; -import { Query, QueryStringInput } from '../../../../../../../src/plugins/data/public'; +import { Query } from '@kbn/es-query'; +import { QueryStringInput } from '../../../../../../../src/plugins/data/public'; import { LogCustomizationMenu } from '../../../components/logging/log_customization_menu'; import { LogDatepicker } from '../../../components/logging/log_datepicker'; import { LogHighlightsMenu } from '../../../components/logging/log_highlights_menu'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts index 67bc13251de20..d28511409fca6 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { fromKueryExpression } from '@kbn/es-query'; import { useState, useMemo, useCallback, useEffect } from 'react'; import * as rt from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -15,11 +16,10 @@ import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill'; import { useUrlState } from '../../../../utils/use_url_state'; import { useSourceContext } from '../../../../containers/metrics_source'; import { convertKueryToElasticSearchQuery } from '../../../../utils/kuery'; -import { esKuery } from '../../../../../../../../src/plugins/data/public'; const validateKuery = (expression: string) => { try { - esKuery.fromKueryExpression(expression); + fromKueryExpression(expression); } catch (err) { return false; } diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx index 2be5f14005b26..710f547d003ca 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx @@ -6,12 +6,12 @@ */ import { i18n } from '@kbn/i18n'; - +import { fromKueryExpression } from '@kbn/es-query'; import React, { useEffect, useState } from 'react'; import { DataViewBase } from '@kbn/es-query'; import { WithKueryAutocompletion } from '../../../../containers/with_kuery_autocompletion'; import { AutocompleteField } from '../../../../components/autocomplete_field'; -import { esKuery, QuerySuggestion } from '../../../../../../../../src/plugins/data/public'; +import { QuerySuggestion } from '../../../../../../../../src/plugins/data/public'; type LoadSuggestionsFn = ( e: string, @@ -32,7 +32,7 @@ interface Props { function validateQuery(query: string) { try { - esKuery.fromKueryExpression(query); + fromKueryExpression(query); } catch (err) { return false; } diff --git a/x-pack/plugins/infra/public/utils/kuery.ts b/x-pack/plugins/infra/public/utils/kuery.ts index 398018ea45142..aec9ec58aabaa 100644 --- a/x-pack/plugins/infra/public/utils/kuery.ts +++ b/x-pack/plugins/infra/public/utils/kuery.ts @@ -5,8 +5,8 @@ * 2.0. */ +import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { DataViewBase } from '@kbn/es-query'; -import { esKuery } from '../../../../../src/plugins/data/public'; export const convertKueryToElasticSearchQuery = ( kueryExpression: string, @@ -15,9 +15,7 @@ export const convertKueryToElasticSearchQuery = ( ) => { try { return kueryExpression - ? JSON.stringify( - esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kueryExpression), indexPattern) - ) + ? JSON.stringify(toElasticsearchQuery(fromKueryExpression(kueryExpression), indexPattern)) : ''; } catch (err) { if (swallowErrors) { diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index fe0570c4950f8..361565c3672c5 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../common/alerting/logs/log_threshold/types'; +import { LOG_DOCUMENT_COUNT_RULE_TYPE_ID } from '../common/alerting/logs/log_threshold/types'; import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from './lib/alerting/inventory_metric_threshold/types'; import { METRIC_THRESHOLD_ALERT_TYPE_ID } from './lib/alerting/metric_threshold/types'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -83,7 +83,7 @@ export const LOGS_FEATURE = { management: { insightsAndAlerting: ['triggersActions'], }, - alerting: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + alerting: [LOG_DOCUMENT_COUNT_RULE_TYPE_ID], privileges: { all: { app: ['infra', 'logs', 'kibana'], @@ -95,10 +95,10 @@ export const LOGS_FEATURE = { }, alerting: { rule: { - all: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + all: [LOG_DOCUMENT_COUNT_RULE_TYPE_ID], }, alert: { - all: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + all: [LOG_DOCUMENT_COUNT_RULE_TYPE_ID], }, }, management: { @@ -112,10 +112,10 @@ export const LOGS_FEATURE = { api: ['infra'], alerting: { rule: { - read: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + read: [LOG_DOCUMENT_COUNT_RULE_TYPE_ID], }, alert: { - read: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + read: [LOG_DOCUMENT_COUNT_RULE_TYPE_ID], }, }, management: { diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts index b5cf05512b353..90f9c508e1038 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts @@ -18,7 +18,7 @@ import { import { Comparator, AlertStates, - AlertParams, + RuleParams, Criterion, UngroupedSearchQueryResponse, GroupedSearchQueryResponse, @@ -126,7 +126,7 @@ const expectedNegativeFilterClauses = [ }, ]; -const baseAlertParams: Pick = { +const baseRuleParams: Pick = { count: { comparator: Comparator.GT, value: 5, @@ -165,27 +165,27 @@ describe('Log threshold executor', () => { }); describe('Criteria filter building', () => { test('Handles positive criteria', () => { - const alertParams: AlertParams = { - ...baseAlertParams, + const ruleParams: RuleParams = { + ...baseRuleParams, criteria: positiveCriteria, }; - const filters = buildFiltersFromCriteria(alertParams, TIMESTAMP_FIELD); + const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD); expect(filters.mustFilters).toEqual(expectedPositiveFilterClauses); }); test('Handles negative criteria', () => { - const alertParams: AlertParams = { - ...baseAlertParams, + const ruleParams: RuleParams = { + ...baseRuleParams, criteria: negativeCriteria, }; - const filters = buildFiltersFromCriteria(alertParams, TIMESTAMP_FIELD); + const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD); expect(filters.mustNotFilters).toEqual(expectedNegativeFilterClauses); }); test('Handles time range', () => { - const alertParams: AlertParams = { ...baseAlertParams, criteria: [] }; - const filters = buildFiltersFromCriteria(alertParams, TIMESTAMP_FIELD); + const ruleParams: RuleParams = { ...baseRuleParams, criteria: [] }; + const filters = buildFiltersFromCriteria(ruleParams, TIMESTAMP_FIELD); expect(typeof filters.rangeFilter.range[TIMESTAMP_FIELD].gte).toBe('number'); expect(typeof filters.rangeFilter.range[TIMESTAMP_FIELD].lte).toBe('number'); expect(filters.rangeFilter.range[TIMESTAMP_FIELD].format).toBe('epoch_millis'); @@ -199,12 +199,12 @@ describe('Log threshold executor', () => { describe('ES queries', () => { describe('Query generation', () => { it('Correctly generates ungrouped queries', () => { - const alertParams: AlertParams = { - ...baseAlertParams, + const ruleParams: RuleParams = { + ...baseRuleParams, criteria: [...positiveCriteria, ...negativeCriteria], }; const query = getUngroupedESQuery( - alertParams, + ruleParams, TIMESTAMP_FIELD, FILEBEAT_INDEX, runtimeMappings @@ -248,13 +248,13 @@ describe('Log threshold executor', () => { describe('Correctly generates grouped queries', () => { it('When using an optimizable threshold comparator', () => { - const alertParams: AlertParams = { - ...baseAlertParams, + const ruleParams: RuleParams = { + ...baseRuleParams, groupBy: ['host.name'], criteria: [...positiveCriteria, ...negativeCriteria], }; const query = getGroupedESQuery( - alertParams, + ruleParams, TIMESTAMP_FIELD, FILEBEAT_INDEX, runtimeMappings @@ -313,10 +313,10 @@ describe('Log threshold executor', () => { }); it('When not using an optimizable threshold comparator', () => { - const alertParams: AlertParams = { - ...baseAlertParams, + const ruleParams: RuleParams = { + ...baseRuleParams, count: { - ...baseAlertParams.count, + ...baseRuleParams.count, comparator: Comparator.LT, }, groupBy: ['host.name'], @@ -324,7 +324,7 @@ describe('Log threshold executor', () => { }; const query = getGroupedESQuery( - alertParams, + ruleParams, TIMESTAMP_FIELD, FILEBEAT_INDEX, runtimeMappings @@ -408,8 +408,8 @@ describe('Log threshold executor', () => { describe('Can process ungrouped results', () => { test('It handles the ALERT state correctly', () => { const alertUpdaterMock = jest.fn(); - const alertParams = { - ...baseAlertParams, + const ruleParams = { + ...baseRuleParams, criteria: [positiveCriteria[0]], }; const results = { @@ -421,7 +421,7 @@ describe('Log threshold executor', () => { } as UngroupedSearchQueryResponse; processUngroupedResults( results, - alertParams, + ruleParams, alertsMock.createAlertInstanceFactory, alertUpdaterMock ); @@ -445,8 +445,8 @@ describe('Log threshold executor', () => { describe('Can process grouped results', () => { test('It handles the ALERT state correctly', () => { const alertUpdaterMock = jest.fn(); - const alertParams = { - ...baseAlertParams, + const ruleParams = { + ...baseRuleParams, criteria: [positiveCriteria[0]], groupBy: ['host.name', 'event.dataset'], }; @@ -485,7 +485,7 @@ describe('Log threshold executor', () => { ] as GroupedSearchQueryResponse['aggregations']['groups']['buckets']; processGroupByResults( results, - alertParams, + ruleParams, alertsMock.createAlertInstanceFactory, alertUpdaterMock ); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index a41c70f5c2869..daf9b486da9a0 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -22,11 +22,11 @@ import { AlertTypeState as RuleTypeState, } from '../../../../../alerting/server'; import { - AlertParams, - alertParamsRT, + RuleParams, + ruleParamsRT, AlertStates, Comparator, - CountAlertParams, + CountRuleParams, CountCriteria, Criterion, getDenominator, @@ -36,8 +36,8 @@ import { hasGroupBy, isOptimizableGroupedThreshold, isOptimizedGroupedSearchQueryResponse, - isRatioAlertParams, - RatioAlertParams, + isRatioRuleParams, + RatioRuleParams, UngroupedSearchQueryResponse, UngroupedSearchQueryResponseRT, } from '../../../../common/alerting/logs/log_threshold'; @@ -54,7 +54,7 @@ import { } from './reason_formatters'; export type LogThresholdActionGroups = ActionGroupIdsOf; -export type LogThresholdRuleTypeParams = AlertParams; +export type LogThresholdRuleTypeParams = RuleParams; export type LogThresholdRuleTypeState = RuleTypeState; // no specific state used export type LogThresholdAlertState = AlertState; // no specific state used export type LogThresholdAlertContext = AlertContext; // no specific instance context used @@ -116,9 +116,9 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => ); try { - const validatedParams = decodeOrThrow(alertParamsRT)(params); + const validatedParams = decodeOrThrow(ruleParamsRT)(params); - if (!isRatioAlertParams(validatedParams)) { + if (!isRatioRuleParams(validatedParams)) { await executeAlert( validatedParams, timestampField, @@ -143,7 +143,7 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => }); async function executeAlert( - alertParams: CountAlertParams, + alertParams: CountRuleParams, timestampField: string, indexPattern: string, runtimeMappings: estypes.MappingRuntimeFields, @@ -174,7 +174,7 @@ async function executeAlert( } async function executeRatioAlert( - alertParams: RatioAlertParams, + alertParams: RatioRuleParams, timestampField: string, indexPattern: string, runtimeMappings: estypes.MappingRuntimeFields, @@ -182,12 +182,12 @@ async function executeRatioAlert( alertFactory: LogThresholdAlertFactory ) { // Ratio alert params are separated out into two standard sets of alert params - const numeratorParams: AlertParams = { + const numeratorParams: RuleParams = { ...alertParams, criteria: getNumerator(alertParams.criteria), }; - const denominatorParams: AlertParams = { + const denominatorParams: RuleParams = { ...alertParams, criteria: getDenominator(alertParams.criteria), }; @@ -228,7 +228,7 @@ async function executeRatioAlert( } const getESQuery = ( - alertParams: Omit & { criteria: CountCriteria }, + alertParams: Omit & { criteria: CountCriteria }, timestampField: string, indexPattern: string, runtimeMappings: estypes.MappingRuntimeFields @@ -240,7 +240,7 @@ const getESQuery = ( export const processUngroupedResults = ( results: UngroupedSearchQueryResponse, - params: CountAlertParams, + params: CountRuleParams, alertFactory: LogThresholdAlertFactory, alertUpdater: AlertUpdater ) => { @@ -271,7 +271,7 @@ export const processUngroupedResults = ( export const processUngroupedRatioResults = ( numeratorResults: UngroupedSearchQueryResponse, denominatorResults: UngroupedSearchQueryResponse, - params: RatioAlertParams, + params: RatioRuleParams, alertFactory: LogThresholdAlertFactory, alertUpdater: AlertUpdater ) => { @@ -344,7 +344,7 @@ const getReducedGroupByResults = ( export const processGroupByResults = ( results: GroupedSearchQueryResponse['aggregations']['groups']['buckets'], - params: CountAlertParams, + params: CountRuleParams, alertFactory: LogThresholdAlertFactory, alertUpdater: AlertUpdater ) => { @@ -385,7 +385,7 @@ export const processGroupByResults = ( export const processGroupByRatioResults = ( numeratorResults: GroupedSearchQueryResponse['aggregations']['groups']['buckets'], denominatorResults: GroupedSearchQueryResponse['aggregations']['groups']['buckets'], - params: RatioAlertParams, + params: RatioRuleParams, alertFactory: LogThresholdAlertFactory, alertUpdater: AlertUpdater ) => { @@ -457,7 +457,7 @@ export const updateAlert: AlertUpdater = (alert, state, actions) => { }; export const buildFiltersFromCriteria = ( - params: Pick & { criteria: CountCriteria }, + params: Pick & { criteria: CountCriteria }, timestampField: string ) => { const { timeSize, timeUnit, criteria } = params; @@ -508,11 +508,11 @@ export const buildFiltersFromCriteria = ( }; export const getGroupedESQuery = ( - params: Pick & { + params: Pick & { criteria: CountCriteria; count: { - comparator: AlertParams['count']['comparator']; - value?: AlertParams['count']['value']; + comparator: RuleParams['count']['comparator']; + value?: RuleParams['count']['value']; }; }, timestampField: string, @@ -619,7 +619,7 @@ export const getGroupedESQuery = ( }; export const getUngroupedESQuery = ( - params: Pick & { criteria: CountCriteria }, + params: Pick & { criteria: CountCriteria }, timestampField: string, index: string, runtimeMappings: estypes.MappingRuntimeFields diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts index 05dc2682fc3b7..9a2902bfb8cd4 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts @@ -9,8 +9,8 @@ import { i18n } from '@kbn/i18n'; import { PluginSetupContract } from '../../../../../alerting/server'; import { createLogThresholdExecutor, FIRED_ACTIONS } from './log_threshold_executor'; import { - LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, - alertParamsRT, + LOG_DOCUMENT_COUNT_RULE_TYPE_ID, + ruleParamsRT, } from '../../../../common/alerting/logs/log_threshold'; import { InfraBackendLibs } from '../../infra_types'; import { decodeOrThrow } from '../../../../common/runtime_types'; @@ -82,13 +82,13 @@ export async function registerLogThresholdRuleType( } alertingPlugin.registerType({ - id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + id: LOG_DOCUMENT_COUNT_RULE_TYPE_ID, name: i18n.translate('xpack.infra.logs.alertName', { defaultMessage: 'Log threshold', }), validate: { params: { - validate: (params) => decodeOrThrow(alertParamsRT)(params), + validate: (params) => decodeOrThrow(ruleParamsRT)(params), }, }, defaultActionGroupId: FIRED_ACTIONS.id, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_rule_type.ts index dd90297355742..02e6028765bee 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_rule_type.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { MlPluginSetup } from '../../../../../ml/server'; import { - AlertType as RuleType, + RuleType, AlertInstanceState as AlertState, AlertInstanceContext as AlertContext, } from '../../../../../alerting/server'; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts index 0a67dbdc3190f..6142e7083e0d2 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { ActionGroupIdsOf } from '../../../../../alerting/common'; -import { AlertType, PluginSetupContract } from '../../../../../alerting/server'; +import { RuleType, PluginSetupContract } from '../../../../../alerting/server'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api'; import { createMetricThresholdExecutor, @@ -31,7 +31,7 @@ import { type MetricThresholdAllowedActionGroups = ActionGroupIdsOf< typeof FIRED_ACTIONS | typeof WARNING_ACTIONS >; -export type MetricThresholdAlertType = Omit & { +export type MetricThresholdAlertType = Omit & { ActionGroupIdsOf: MetricThresholdAllowedActionGroups; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/index.tsx b/x-pack/plugins/ingest_pipelines/public/application/index.tsx index fb185537b1aca..56c38e847e6b3 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/index.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/index.tsx @@ -8,11 +8,13 @@ import { HttpSetup } from 'kibana/public'; import React, { ReactNode } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { NotificationsSetup, IUiSettingsClient } from 'kibana/public'; +import { Observable } from 'rxjs'; + +import { NotificationsSetup, IUiSettingsClient, CoreTheme } from 'kibana/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; import { SharePluginStart } from 'src/plugins/share/public'; import type { FileUploadPluginStart } from '../../../file_upload/public'; -import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { KibanaContextProvider, KibanaThemeProvider } from '../shared_imports'; import { API_BASE_PATH } from '../../common/constants'; @@ -48,7 +50,8 @@ export const renderApp = ( element: HTMLElement, I18nContext: ({ children }: { children: ReactNode }) => JSX.Element, services: AppServices, - coreServices: CoreServices + coreServices: CoreServices, + { theme$ }: { theme$: Observable } ) => { render( - - - + + + + + , element diff --git a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts index 025a661477a24..c21efb52dd7e1 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts @@ -22,7 +22,7 @@ export async function mountManagementSection( { http, getStartServices, notifications }: CoreSetup, params: ManagementAppMountParams ) { - const { element, setBreadcrumbs, history } = params; + const { element, setBreadcrumbs, history, theme$ } = params; const [coreStart, depsStart] = await getStartServices(); const { docLinks, @@ -45,5 +45,5 @@ export async function mountManagementSection( fileUpload: depsStart.fileUpload, }; - return renderApp(element, I18nContext, services, { http }); + return renderApp(element, I18nContext, services, { http }, { theme$ }); } diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts index bcc0060c1e5d4..f4c24f622e752 100644 --- a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts +++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts @@ -17,6 +17,7 @@ export type { UseRequestConfig, OnJsonEditorUpdateHandler, } from '../../../../src/plugins/es_ui_shared/public/'; + export { AuthorizationProvider, NotAuthorizedSection, @@ -46,6 +47,7 @@ export type { FormOptions, SerializerFunc, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; + export { FIELD_TYPES, useForm, @@ -84,6 +86,9 @@ export { isEmptyString, } from '../../../../src/plugins/es_ui_shared/static/validators/string'; -export { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; +export { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../src/plugins/kibana_react/public'; export const useKibana = () => _useKibana(); diff --git a/x-pack/plugins/lens/jest.config.js b/x-pack/plugins/lens/jest.config.js index f1164df4eab86..ae4a39557bec8 100644 --- a/x-pack/plugins/lens/jest.config.js +++ b/x-pack/plugins/lens/jest.config.js @@ -12,4 +12,5 @@ module.exports = { coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/lens', coverageReporters: ['text', 'html'], collectCoverageFrom: ['/x-pack/plugins/lens/{common,public,server}/**/*.{ts,tsx}'], + setupFiles: ['jest-canvas-mock'], }; diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 7748a5fe37179..08f046925cb46 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -21,10 +21,8 @@ import { mockStoreDeps, } from '../mocks'; import { I18nProvider } from '@kbn/i18n-react'; -import { - SavedObjectSaveModal, - checkForDuplicateTitle, -} from '../../../../../src/plugins/saved_objects/public'; +import { SavedObjectSaveModal } from '../../../../../src/plugins/saved_objects/public'; +import { checkForDuplicateTitle } from '../persistence'; import { createMemoryHistory } from 'history'; import { esFilters, @@ -42,17 +40,9 @@ import moment from 'moment'; import { setState, LensAppState } from '../state_management/index'; jest.mock('../editor_frame_service/editor_frame/expression_helpers'); jest.mock('src/core/public'); -jest.mock('../../../../../src/plugins/saved_objects/public', () => { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { SavedObjectSaveModal, SavedObjectSaveModalOrigin } = jest.requireActual( - '../../../../../src/plugins/saved_objects/public' - ); - return { - SavedObjectSaveModal, - SavedObjectSaveModalOrigin, - checkForDuplicateTitle: jest.fn(), - }; -}); +jest.mock('../persistence/saved_objects_utils/check_for_duplicate_title', () => ({ + checkForDuplicateTitle: jest.fn(), +})); jest.mock('lodash', () => { const original = jest.requireActual('lodash'); diff --git a/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx b/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx index 0f99902e0b10a..b3bc40ef5ac19 100644 --- a/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx +++ b/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx @@ -14,12 +14,11 @@ import type { SavedObjectReference } from 'kibana/public'; import { SaveModal } from './save_modal'; import type { LensAppProps, LensAppServices } from './types'; import type { SaveProps } from './app'; -import { Document, injectFilterReferences } from '../persistence'; +import { Document, injectFilterReferences, checkForDuplicateTitle } from '../persistence'; import type { LensByReferenceInput, LensEmbeddableInput } from '../embeddable'; import { esFilters } from '../../../../../src/plugins/data/public'; import { APP_ID, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../common'; import { trackUiEvent } from '../lens_ui_telemetry'; -import { checkForDuplicateTitle } from '../../../../../src/plugins/saved_objects/public'; import type { LensAppState } from '../state_management'; import { getPersisted } from '../state_management/init_middleware/load_initial'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 37191ffa89fdc..f7fcc012ec168 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -475,6 +475,7 @@ describe('editor_frame', () => { datasourceId: 'testDatasource', getOperationForColumnId: jest.fn(), getTableSpec: jest.fn(), + getVisualDefaults: jest.fn(), }; mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index 0d68e2d72e73b..9d1e5910b468d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -540,6 +540,7 @@ describe('suggestion helpers', () => { getTableSpec: () => [{ columnId: 'col1' }], datasourceId: '', getOperationForColumnId: jest.fn(), + getVisualDefaults: jest.fn(), }, }, { activeId: 'testVis', state: {} }, @@ -597,6 +598,7 @@ describe('suggestion helpers', () => { getTableSpec: () => [], datasourceId: '', getOperationForColumnId: jest.fn(), + getVisualDefaults: jest.fn(), }, }; mockVisualization1.getSuggestions.mockReturnValue([]); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx index 88966acd22691..a4806e0849db4 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import type { VisualizationToolbarProps } from '../types'; import { LegendSettingsPopover, ToolbarPopover, ValueLabelsSettings } from '../shared_components'; import type { HeatmapVisualizationState } from './types'; +import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [ { @@ -32,9 +33,13 @@ const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: export const HeatmapToolbar = memo( (props: VisualizationToolbarProps) => { - const { state, setState } = props; + const { state, setState, frame } = props; const legendMode = state.legend.isVisible ? 'show' : 'hide'; + const defaultTruncationValue = getDefaultVisualValuesForLayer( + state, + frame.datasourceLayers + ).truncateText; return ( @@ -90,9 +95,9 @@ export const HeatmapToolbar = memo( legend: { ...state.legend, maxLines: val }, }); }} - shouldTruncate={state?.legend.shouldTruncate ?? true} + shouldTruncate={state?.legend.shouldTruncate ?? defaultTruncationValue} onTruncateLegendChange={() => { - const current = state.legend.shouldTruncate ?? true; + const current = state.legend.shouldTruncate ?? defaultTruncationValue; setState({ ...state, legend: { ...state.legend, shouldTruncate: !current }, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index 68039e79ea45c..6e61bb684fa91 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -68,7 +68,6 @@ describe('heatmap', () => { position: Position.Right, type: LEGEND_FUNCTION, maxLines: 1, - shouldTruncate: true, }, gridConfig: { type: HEATMAP_GRID_FUNCTION, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index bf645599cae11..6a654a020bc23 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -74,7 +74,6 @@ function getInitialState(): Omit
@@ -491,61 +480,34 @@ export function DimensionEditor(props: DimensionEditorProps) { {!selectedColumn || selectedOperationDefinition?.input === 'field' || - (incompleteOperation && operationDefinitionMap[incompleteOperation].input === 'field') || + (incompleteOperation && operationDefinitionMap[incompleteOperation]?.input === 'field') || temporaryQuickFunction ? ( - - { + if (temporaryQuickFunction) { + setTemporaryState('none'); } - incompleteOperation={incompleteOperation} - onChoose={(choice) => { - if (temporaryQuickFunction) { - setTemporaryState('none'); - } - setStateWrapper( - insertOrReplaceColumn({ - layer: state.layers[layerId], - columnId, - indexPattern: currentIndexPattern, - op: choice.operationType, - field: currentIndexPattern.getFieldByName(choice.field), - visualizationGroups: dimensionGroups, - targetGroup: props.groupId, - incompleteParams, - }), - { forceRender: temporaryQuickFunction } - ); - }} - /> - + setStateWrapper(newLayer, { forceRender: temporaryQuickFunction }); + }} + incompleteField={incompleteField} + incompleteOperation={incompleteOperation} + incompleteParams={incompleteParams} + currentFieldIsInvalid={currentFieldIsInvalid} + helpMessage={selectedOperationDefinition?.getHelpMessage?.({ + data: props.data, + uiSettings: props.uiSettings, + currentColumn: state.layers[layerId].columns[columnId], + })} + dimensionGroups={dimensionGroups} + groupId={props.groupId} + operationDefinitionMap={operationDefinitionMap} + /> ) : null} {shouldDisplayExtraOptions && ParamEditor && ( @@ -553,7 +515,12 @@ export function DimensionEditor(props: DimensionEditorProps) { layer={state.layers[layerId]} layerId={layerId} activeData={props.activeData} - updateLayer={setStateWrapper} + updateLayer={(setter) => { + if (temporaryQuickFunction) { + setTemporaryState('none'); + } + setStateWrapper(setter, { forceRender: temporaryQuickFunction }); + }} columnId={columnId} currentColumn={state.layers[layerId].columns[columnId]} dateRange={dateRange} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 2ff2fd67435ab..dd16b0be6ce61 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -6,7 +6,6 @@ */ import { ReactWrapper, ShallowWrapper } from 'enzyme'; -import 'jest-canvas-mock'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx index bcfa0d797b51c..033ac9c707151 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx @@ -16,7 +16,7 @@ import './dimension_editor.scss'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiFieldText, EuiTabs, EuiTab, EuiCallOut } from '@elastic/eui'; -import { GenericIndexPatternColumn, operationDefinitionMap } from '../operations'; +import { operationDefinitionMap } from '../operations'; import { useDebouncedValue } from '../../shared_components'; export const formulaOperationName = 'formula'; @@ -174,26 +174,3 @@ export const DimensionEditorTabs = ({ tabs }: { tabs: DimensionEditorTab[] }) => ); }; - -export function getErrorMessage( - selectedColumn: GenericIndexPatternColumn | undefined, - incompleteOperation: boolean, - input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined, - fieldInvalid: boolean -) { - if (selectedColumn && incompleteOperation) { - if (input === 'field') { - return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { - defaultMessage: 'This field does not work with the selected function.', - }); - } - return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { - defaultMessage: 'To use this function, select a field.', - }); - } - if (fieldInvalid) { - return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', { - defaultMessage: 'Invalid field. Check your data view or pick another field.', - }); - } -} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts index fcc9a57285ba6..87daef0d40f62 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -13,7 +13,7 @@ import { VisualizationDimensionGroupConfig, } from '../../../types'; import { getOperationDisplay } from '../../operations'; -import { hasField, isDraggedField } from '../../utils'; +import { hasField, isDraggedField } from '../../pure_utils'; import { DragContextState } from '../../../drag_drop/providers'; import { OperationMetadata } from '../../../types'; import { getOperationTypesForField } from '../../operations'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index 0c538d0fc9486..1b5679786e717 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -13,7 +13,7 @@ import { copyColumn, } from '../../operations'; import { mergeLayer } from '../../state_helpers'; -import { isDraggedField } from '../../utils'; +import { isDraggedField } from '../../pure_utils'; import { getNewOperation, getField } from './get_drop_props'; import { IndexPatternPrivateState, DraggedField } from '../../types'; import { trackUiEvent } from '../../../lens_ui_telemetry'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx new file mode 100644 index 0000000000000..cf409ebfd680d --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx @@ -0,0 +1,487 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { EuiComboBox } from '@elastic/eui'; +import { GenericOperationDefinition } from '../operations'; +import { + averageOperation, + countOperation, + derivativeOperation, + FieldBasedIndexPatternColumn, + termsOperation, + staticValueOperation, +} from '../operations/definitions'; +import { FieldInput, getErrorMessage } from './field_input'; +import { createMockedIndexPattern } from '../mocks'; +import { getOperationSupportMatrix } from '.'; +import { GenericIndexPatternColumn, IndexPatternLayer, IndexPatternPrivateState } from '../types'; +import { ReferenceBasedIndexPatternColumn } from '../operations/definitions/column_types'; + +jest.mock('../operations/layer_helpers', () => { + const original = jest.requireActual('../operations/layer_helpers'); + + return { + ...original, + insertOrReplaceColumn: () => { + return {} as IndexPatternLayer; + }, + }; +}); + +const defaultProps = { + indexPattern: createMockedIndexPattern(), + currentFieldIsInvalid: false, + incompleteField: null, + incompleteOperation: undefined, + incompleteParams: {}, + dimensionGroups: [], + groupId: 'any', + operationDefinitionMap: { + terms: termsOperation, + average: averageOperation, + count: countOperation, + differences: derivativeOperation, + staticValue: staticValueOperation, + } as unknown as Record, +}; + +function getStringBasedOperationColumn(field = 'source'): FieldBasedIndexPatternColumn { + return { + label: `Top value of ${field}`, + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: field, + } as FieldBasedIndexPatternColumn; +} + +function getReferenceBasedOperationColumn( + subOp = 'average', + field = 'bytes' +): ReferenceBasedIndexPatternColumn { + return { + label: `Difference of ${subOp} of ${field}`, + dataType: 'number', + operationType: 'differences', + isBucketed: false, + references: ['colX'], + scale: 'ratio', + }; +} + +function getManagedBasedOperationColumn(): ReferenceBasedIndexPatternColumn { + return { + label: 'Static value: 100', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: 100 }, + references: [], + } as ReferenceBasedIndexPatternColumn; +} + +function getCountOperationColumn(): GenericIndexPatternColumn { + return { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }; +} +function getLayer(col1: GenericIndexPatternColumn = getStringBasedOperationColumn()) { + return { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1, + col2: getCountOperationColumn(), + }, + }; +} +function getDefaultOperationSupportMatrix( + layer: IndexPatternLayer, + columnId: string, + existingFields: Record> +) { + return getOperationSupportMatrix({ + state: { + layers: { layer1: layer }, + indexPatterns: { + [defaultProps.indexPattern.id]: defaultProps.indexPattern, + }, + existingFields, + } as unknown as IndexPatternPrivateState, + layerId: 'layer1', + filterOperations: () => true, + columnId, + }); +} + +function getExistingFields(layer: IndexPatternLayer) { + const fields: Record = {}; + for (const field of defaultProps.indexPattern.fields) { + fields[field.name] = true; + } + return { + [layer.indexPatternId]: fields, + }; +} + +describe('FieldInput', () => { + it('should render a field select box', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect(instance.find('[data-test-subj="indexPattern-dimension-field"]').exists()).toBeTruthy(); + }); + + it('should render an error message when incomplete operation is on', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('isInvalid') + ).toBeTruthy(); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('This field does not work with the selected function.'); + }); + + it.each([ + ['reference-based operation', getReferenceBasedOperationColumn()], + ['managed references operation', getManagedBasedOperationColumn()], + ])( + 'should mark the field as invalid but not show any error message for a %s when only an incomplete column is set', + (_, col: ReferenceBasedIndexPatternColumn) => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(col); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix( + layer, + 'col1', + existingFields + ); + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeTruthy(); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe(undefined); + } + ); + + it.each([ + ['reference-based operation', getReferenceBasedOperationColumn()], + ['managed references operation', getManagedBasedOperationColumn()], + ])( + 'should mark the field as invalid but and show an error message for a %s when an incomplete column is set and an existing column is selected', + (_, col: ReferenceBasedIndexPatternColumn) => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(col); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix( + layer, + 'col1', + existingFields + ); + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeTruthy(); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('This field does not work with the selected function.'); + } + ); + + it('should render an error message for invalid fields', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('isInvalid') + ).toBeTruthy(); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('Invalid field. Check your data view or pick another field.'); + }); + + it('should render a help message when passed and no errors are found', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('labelAppend') + ).toBe('My help message'); + }); + + it('should prioritize errors over help messages', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('labelAppend') + ).not.toBe('My help message'); + }); + + it('should update the layer on field selection', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + act(() => { + instance.find(EuiComboBox).first().prop('onChange')!([ + { value: { type: 'field', field: 'dest' }, label: 'dest' }, + ]); + }); + + expect(updateLayerSpy).toHaveBeenCalled(); + }); + + it('should not trigger when the same selected field is selected again', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + act(() => { + instance.find(EuiComboBox).first().prop('onChange')!([ + { value: { type: 'field', field: 'source' }, label: 'source' }, + ]); + }); + + expect(updateLayerSpy).not.toHaveBeenCalled(); + }); + + it('should prioritize incomplete fields over selected column field to display', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect(instance.find(EuiComboBox).first().prop('selectedOptions')).toEqual([ + { + label: 'dest', + value: { type: 'field', field: 'dest' }, + }, + ]); + }); + + it('should forward the onDeleteColumn function', () => { + const updateLayerSpy = jest.fn(); + const onDeleteColumn = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + act(() => { + instance.find(EuiComboBox).first().prop('onChange')!([]); + }); + + expect(onDeleteColumn).toHaveBeenCalled(); + expect(updateLayerSpy).not.toHaveBeenCalled(); + }); +}); + +describe('getErrorMessage', () => { + it.each(['none', 'field', 'fullReference', 'managedReference'] as const)( + 'should return no error for no column passed for %s type of operation', + (type) => { + expect(getErrorMessage(undefined, false, type, false)).toBeUndefined(); + } + ); + + it('should return the invalid message', () => { + expect(getErrorMessage(undefined, false, 'none', true)).toBe( + 'Invalid field. Check your data view or pick another field.' + ); + }); + + it('should ignore the invalid flag when an incomplete column is passed', () => { + expect( + getErrorMessage( + { operationType: 'terms', label: 'Top values of X', dataType: 'string', isBucketed: true }, + true, + 'field', + true + ) + ).not.toBe('Invalid field. Check your data view or pick another field.'); + }); + + it('should tell the user to change field if incomplete with an incompatible field', () => { + expect( + getErrorMessage( + { operationType: 'terms', label: 'Top values of X', dataType: 'string', isBucketed: true }, + true, + 'field', + false + ) + ).toBe('This field does not work with the selected function.'); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.tsx new file mode 100644 index 0000000000000..ad3aa97b2a0ea --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { insertOrReplaceColumn } from '../operations/layer_helpers'; +import { FieldSelect } from './field_select'; +import type { + FieldInputProps, + OperationType, + GenericIndexPatternColumn, +} from '../operations/definitions'; +import type { FieldBasedIndexPatternColumn } from '../operations/definitions/column_types'; + +export function FieldInput({ + layer, + selectedColumn, + columnId, + indexPattern, + existingFields, + operationSupportMatrix, + updateLayer, + onDeleteColumn, + incompleteField, + incompleteOperation, + incompleteParams, + currentFieldIsInvalid, + helpMessage, + groupId, + dimensionGroups, + operationDefinitionMap, +}: FieldInputProps) { + const selectedOperationDefinition = + selectedColumn && operationDefinitionMap[selectedColumn.operationType]; + // Need to workout early on the error to decide whether to show this or an help text + const fieldErrorMessage = + ((selectedOperationDefinition?.input !== 'fullReference' && + selectedOperationDefinition?.input !== 'managedReference') || + (incompleteOperation && operationDefinitionMap[incompleteOperation].input === 'field')) && + getErrorMessage( + selectedColumn, + Boolean(incompleteOperation), + selectedOperationDefinition?.input, + currentFieldIsInvalid + ); + return ( + + { + return updateLayer( + insertOrReplaceColumn({ + layer, + columnId, + indexPattern, + op: choice.operationType, + field: indexPattern.getFieldByName(choice.field), + visualizationGroups: dimensionGroups, + targetGroup: groupId, + incompleteParams, + }) + ); + }} + /> + + ); +} + +export function getErrorMessage( + selectedColumn: GenericIndexPatternColumn | undefined, + incompleteOperation: boolean, + input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined, + fieldInvalid: boolean +) { + if (selectedColumn && incompleteOperation) { + if (input === 'field') { + return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { + defaultMessage: 'This field does not work with the selected function.', + }); + } + return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { + defaultMessage: 'To use this function, select a field.', + }); + } + if (fieldInvalid) { + return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', { + defaultMessage: 'Invalid field. Check your data view or pick another field.', + }); + } +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index 003af1f3ed4a7..f775026d54921 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -18,14 +18,14 @@ import { EuiComboBoxProps, } from '@elastic/eui'; import classNames from 'classnames'; -import { OperationType } from '../indexpattern'; import { LensFieldIcon } from '../lens_field_icon'; -import { DataType } from '../../types'; -import { OperationSupportMatrix } from './operation_support'; -import { IndexPattern, IndexPatternPrivateState } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { fieldExists } from '../pure_helpers'; import { TruncatedLabel } from './truncated_label'; +import type { OperationType } from '../indexpattern'; +import type { DataType } from '../../types'; +import type { OperationSupportMatrix } from './operation_support'; +import type { IndexPattern, IndexPatternPrivateState } from '../types'; export interface FieldChoice { type: 'field'; field: string; @@ -37,12 +37,13 @@ export interface FieldSelectProps extends EuiComboBoxProps void; onDeleteColumn?: () => void; existingFields: IndexPatternPrivateState['existingFields']; fieldIsInvalid: boolean; markAllFieldsCompatible?: boolean; + 'data-test-subj'?: string; } const DEFAULT_COMBOBOX_WIDTH = 305; @@ -54,15 +55,15 @@ export function FieldSelect({ incompleteOperation, selectedOperationType, selectedField, - operationSupportMatrix, + operationByField, onChoose, onDeleteColumn, existingFields, fieldIsInvalid, markAllFieldsCompatible, + ['data-test-subj']: dataTestSub, ...rest }: FieldSelectProps) { - const { operationByField } = operationSupportMatrix; const memoizedFieldOptions = useMemo(() => { const fields = Object.keys(operationByField).sort(); @@ -85,6 +86,9 @@ export function FieldSelect({ return items .filter((field) => currentIndexPattern.getFieldByName(field)?.displayName) .map((field) => { + const compatible = + markAllFieldsCompatible || isCompatibleWithCurrentOperation(field) ? 1 : 0; + const exists = containsData(field); return { label: currentIndexPattern.getFieldByName(field)?.displayName, value: { @@ -99,30 +103,18 @@ export function FieldSelect({ ? currentOperationType : operationByField[field]!.values().next().value, }, - exists: containsData(field), - compatible: markAllFieldsCompatible || isCompatibleWithCurrentOperation(field), + exists, + compatible, + className: classNames({ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'lnFieldSelect__option--incompatible': !compatible, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'lnFieldSelect__option--nonExistant': !exists, + }), + 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${field}`, }; }) - .sort((a, b) => { - if (a.compatible && !b.compatible) { - return -1; - } - if (!a.compatible && b.compatible) { - return 1; - } - return 0; - }) - .map(({ label, value, compatible, exists }) => ({ - label, - value, - className: classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention - 'lnFieldSelect__option--incompatible': !compatible, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'lnFieldSelect__option--nonExistant': !exists, - }), - 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${value.field}`, - })); + .sort((a, b) => b.compatible - a.compatible); } const [metaFields, nonMetaFields] = partition( @@ -207,7 +199,7 @@ export function FieldSelect({ fullWidth compressed isClearable={false} - data-test-subj="indexPattern-dimension-field" + data-test-subj={dataTestSub ?? 'indexPattern-dimension-field'} placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', { defaultMessage: 'Field', })} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx index 16251654a6355..4a16739e65972 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx @@ -10,7 +10,6 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { EuiComboBox } from '@elastic/eui'; import { mountWithIntl as mount } from '@kbn/test/jest'; -import 'jest-canvas-mock'; import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import type { DataPublicPluginStart } from 'src/plugins/data/public'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx index 6fa1912effc2a..a59229ad093b6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx @@ -31,11 +31,11 @@ import { RequiredReference, } from '../operations'; import { FieldSelect } from './field_select'; -import { hasField } from '../utils'; +import { hasField } from '../pure_utils'; import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import { ParamEditorCustomProps, VisualizationDimensionGroupConfig } from '../../types'; -import { IndexPatternDimensionEditorProps } from './dimension_panel'; +import type { ParamEditorCustomProps, VisualizationDimensionGroupConfig } from '../../types'; +import type { IndexPatternDimensionEditorProps } from './dimension_panel'; const operationPanels = getOperationDisplay(); @@ -305,7 +305,7 @@ export function ReferenceEditor(props: ReferenceEditorProps) { { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index d7ea174718813..50f72e5d2cd7b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -6,7 +6,6 @@ */ import React from 'react'; -import 'jest-canvas-mock'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { getIndexPatternDatasource, GenericIndexPatternColumn } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource, FramePublicAPI } from '../types'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 6179f34226125..49a85f3f3af79 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -42,7 +42,8 @@ import { getDatasourceSuggestionsForVisualizeField, } from './indexpattern_suggestions'; -import { isColumnInvalid, isDraggedField, normalizeOperationDataType } from './utils'; +import { getVisualDefaultsForLayer, isColumnInvalid } from './utils'; +import { normalizeOperationDataType, isDraggedField } from './pure_utils'; import { LayerPanel } from './layerpanel'; import { GenericIndexPatternColumn, getErrorMessages, insertNewColumn } from './operations'; import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types'; @@ -448,6 +449,10 @@ export function getIndexPatternDatasource({ } return null; }, + getVisualDefaults: () => { + const layer = state.layers[layerId]; + return getVisualDefaultsForLayer(layer); + }, }; }, getDatasourceSuggestionsForField(state, draggedField, filterLayers) { 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 783314968633f..f9f720cfa922a 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 @@ -8,7 +8,6 @@ import { DatasourceSuggestion } from '../types'; import { generateId } from '../id_generator'; import type { IndexPatternPrivateState } from './types'; -import 'jest-canvas-mock'; import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, 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 6b15a5a8d1daf..3b7d87c00c2da 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -23,7 +23,7 @@ import { getReferencedColumnIds, hasTermsWithManyBuckets, } from './operations'; -import { hasField } from './utils'; +import { hasField } from './pure_utils'; import type { IndexPattern, IndexPatternPrivateState, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx index fa4d3e5e1513d..2bde60c71f53c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { FieldIcon, FieldIconProps } from '@kbn/react-field/field_icon'; import { DataType } from '../types'; -import { normalizeOperationDataType } from './utils'; +import { normalizeOperationDataType } from './pure_utils'; export function LensFieldIcon({ type, ...rest }: FieldIconProps & { type: DataType }) { return ( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index b215e6ed7e318..8d0a07cffd2e1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -173,6 +173,118 @@ describe('filters', () => { }); }); + describe('buildColumn', () => { + it('should build a column with a default query', () => { + expect( + filtersOperation.buildColumn({ + previousColumn: undefined, + layer, + indexPattern: defaultProps.indexPattern, + }) + ).toEqual({ + label: 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: [ + { + input: { + query: '', + language: 'kuery', + }, + label: '', + }, + ], + }, + }); + }); + + it('should inherit terms field when transitioning to filters', () => { + expect( + filtersOperation.buildColumn({ + previousColumn: { + operationType: 'terms', + sourceField: 'bytes', + label: 'Top values of bytes', + isBucketed: true, + dataType: 'number', + params: { + // let's ignore terms params here + format: { id: 'number', params: { decimals: 0 } }, + }, + }, + layer, + indexPattern: defaultProps.indexPattern, + }) + ).toEqual({ + label: 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: [ + { + input: { + query: 'bytes : *', + language: 'kuery', + }, + label: '', + }, + ], + }, + }); + }); + + it('should carry over multi terms as multiple filters', () => { + expect( + filtersOperation.buildColumn({ + previousColumn: { + operationType: 'terms', + sourceField: 'bytes', + label: 'Top values of bytes', + isBucketed: true, + dataType: 'number', + params: { + // let's ignore terms params here + format: { id: 'number', params: { decimals: 0 } }, + // @ts-expect-error not defined in the generic type, only in the Terms specific type + secondaryFields: ['dest'], + }, + }, + layer, + indexPattern: defaultProps.indexPattern, + }) + ).toEqual({ + label: 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: [ + { + input: { + query: 'bytes : *', + language: 'kuery', + }, + label: '', + }, + { + input: { + query: 'dest : *', + language: 'kuery', + }, + label: '', + }, + ], + }, + }); + }); + }); + describe('popover param editor', () => { // @ts-expect-error window['__react-beautiful-dnd-disable-dev-warnings'] = true; // issue with enzyme & react-beautiful-dnd throwing errors: https://github.com/atlassian/react-beautiful-dnd/issues/1593 diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index 08cd12556eaed..f7537cffb112e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -101,6 +101,15 @@ export const filtersOperation: OperationDefinition ({ + label: '', + input: { + query: `${field} : *`, + language: 'kuery', + }, + })) ?? []), ], }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index f18bdb9498f25..275ad1798c788 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -36,6 +36,7 @@ import { lastValueOperation } from './last_value'; import { FrameDatasourceAPI, OperationMetadata, ParamEditorCustomProps } from '../../../types'; import type { BaseIndexPatternColumn, + IncompleteColumn, GenericIndexPatternColumn, ReferenceBasedIndexPatternColumn, } from './column_types'; @@ -44,7 +45,7 @@ import { DateRange, LayerType } from '../../../../common'; import { ExpressionAstFunction } from '../../../../../../../src/plugins/expressions/public'; import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { rangeOperation } from './ranges'; -import { IndexPatternDimensionEditorProps } from '../../dimension_panel'; +import { IndexPatternDimensionEditorProps, OperationSupportMatrix } from '../../dimension_panel'; export type { IncompleteColumn, @@ -158,6 +159,30 @@ export interface ParamEditorProps { paramEditorCustomProps?: ParamEditorCustomProps; } +export interface FieldInputProps { + layer: IndexPatternLayer; + selectedColumn?: C; + columnId: string; + indexPattern: IndexPattern; + updateLayer: ( + setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + ) => void; + onDeleteColumn?: () => void; + currentFieldIsInvalid: boolean; + incompleteField: IncompleteColumn['sourceField'] | null; + incompleteOperation: IncompleteColumn['operationType']; + incompleteParams: Omit; + dimensionGroups: IndexPatternDimensionEditorProps['dimensionGroups']; + groupId: IndexPatternDimensionEditorProps['groupId']; + /** + * indexPatternId -> fieldName -> boolean + */ + existingFields: Record>; + operationSupportMatrix: OperationSupportMatrix; + helpMessage?: React.ReactNode; + operationDefinitionMap: Record; +} + export interface HelpProps { currentColumn: C; uiSettings: IUiSettingsClient; @@ -199,7 +224,7 @@ interface BaseOperationDefinitionProps { changedColumnId: string ) => C; /** - * React component for operation specific settings shown in the popover editor + * React component for operation specific settings shown in the flyout editor */ paramEditor?: React.ComponentType>; /** @@ -281,6 +306,18 @@ interface BaseOperationDefinitionProps { description: string; section: 'elasticsearch' | 'calculation'; }; + /** + * React component for operation field specific behaviour + */ + renderFieldInput?: React.ComponentType>; + /** + * Verify if the a new field can be added to the column + */ + canAddNewField?: (column: C, field: IndexPatternField) => boolean; + /** + * Operation can influence some visual default settings. This function is used to collect default values offered + */ + getDefaultVisualSettings?: (column: C) => { truncateText?: boolean }; } interface BaseBuildColumnArgs { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx index b2cfc0e5a7c2c..0f4ba342348cf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx @@ -20,12 +20,23 @@ import { EuiButtonEmpty, } from '@elastic/eui'; -export const NewBucketButton = ({ label, onClick }: { label: string; onClick: () => void }) => ( +export const NewBucketButton = ({ + label, + onClick, + ['data-test-subj']: dataTestSubj, + isDisabled, +}: { + label: string; + onClick: () => void; + 'data-test-subj'?: string; + isDisabled?: boolean; +}) => ( {label} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx new file mode 100644 index 0000000000000..de04e51ef872c --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx @@ -0,0 +1,234 @@ +/* + * Copyright 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, useMemo } from 'react'; +import { + EuiButtonIcon, + EuiDraggable, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + htmlIdGenerator, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DragDropBuckets, NewBucketButton } from '../shared_components/buckets'; +import { TooltipWrapper, useDebouncedValue } from '../../../../shared_components'; +import { FieldSelect } from '../../../dimension_panel/field_select'; +import type { TermsIndexPatternColumn } from './types'; +import type { IndexPattern, IndexPatternPrivateState } from '../../../types'; +import type { OperationSupportMatrix } from '../../../dimension_panel'; + +const generateId = htmlIdGenerator(); +export const MAX_MULTI_FIELDS_SIZE = 3; + +export interface FieldInputsProps { + column: TermsIndexPatternColumn; + indexPattern: IndexPattern; + existingFields: IndexPatternPrivateState['existingFields']; + operationSupportMatrix: Pick; + onChange: (newValues: string[]) => void; +} + +interface WrappedValue { + id: string; + value: string | undefined; + isNew?: boolean; +} + +type SafeWrappedValue = Omit & { value: string }; + +function removeNewEmptyField(v: WrappedValue): v is SafeWrappedValue { + return v.value != null; +} + +export function FieldInputs({ + column, + onChange, + indexPattern, + existingFields, + operationSupportMatrix, +}: FieldInputsProps) { + const onChangeWrapped = useCallback( + (values: WrappedValue[]) => + onChange(values.filter(removeNewEmptyField).map(({ value }) => value)), + [onChange] + ); + const { wrappedValues, rawValuesLookup } = useMemo(() => { + const rawValues = column ? [column.sourceField, ...(column.params?.secondaryFields || [])] : []; + return { + wrappedValues: rawValues.map((value) => ({ id: generateId(), value })), + rawValuesLookup: new Set(rawValues), + }; + }, [column]); + + const { inputValue: localValues, handleInputChange } = useDebouncedValue({ + onChange: onChangeWrapped, + value: wrappedValues, + }); + + const onFieldSelectChange = useCallback( + (choice, index = 0) => { + const fields = [...localValues]; + const newFieldName = indexPattern.getFieldByName(choice.field)?.displayName; + if (newFieldName != null) { + fields[index] = { id: generateId(), value: newFieldName }; + + // update the layer state + handleInputChange(fields); + } + }, + [localValues, indexPattern, handleInputChange] + ); + + // diminish attention to adding fields alternative + if (localValues.length === 1) { + const [{ value }] = localValues; + return ( + <> + + { + handleInputChange([ + ...localValues, + { id: generateId(), value: undefined, isNew: true }, + ]); + }} + label={i18n.translate('xpack.lens.indexPattern.terms.addField', { + defaultMessage: 'Add field', + })} + /> + + ); + } + const disableActions = localValues.length === 2 && localValues.some(({ isNew }) => isNew); + const localValuesFilled = localValues.filter(({ isNew }) => !isNew); + return ( + <> + { + handleInputChange(updatedValues); + }} + onDragStart={() => {}} + droppableId="TOP_TERMS_DROPPABLE_AREA" + items={localValues} + > + {localValues.map(({ id, value, isNew }, index) => { + // need to filter the available fields for multiple terms + // * a scripted field should be removed + // * if a field has been used, should it be removed? Probably yes? + // * if a scripted field was used in a singular term, should it be marked as invalid for multi-terms? Probably yes? + const filteredOperationByField = Object.keys(operationSupportMatrix.operationByField) + .filter( + (key) => + (!rawValuesLookup.has(key) && !indexPattern.getFieldByName(key)?.scripted) || + key === value + ) + .reduce((memo, key) => { + memo[key] = operationSupportMatrix.operationByField[key]; + return memo; + }, {}); + + const shouldShowScriptedFieldError = Boolean( + value && indexPattern.getFieldByName(value)?.scripted && localValuesFilled.length > 1 + ); + return ( + + {(provided) => ( + + {/* Empty for spacing */} + + + + + { + onFieldSelectChange(choice, index); + }} + isInvalid={shouldShowScriptedFieldError} + data-test-subj={`indexPattern-dimension-field-${index}`} + /> + + + + { + handleInputChange(localValues.filter((_, i) => i !== index)); + }} + data-test-subj={`indexPattern-terms-removeField-${index}`} + isDisabled={disableActions && !isNew} + /> + + + + )} + + ); + })} + + { + handleInputChange([...localValues, { id: generateId(), value: undefined, isNew: true }]); + }} + data-test-subj={`indexPattern-terms-add-field`} + label={i18n.translate('xpack.lens.indexPattern.terms.addaFilter', { + defaultMessage: 'Add field', + })} + isDisabled={localValues.length > MAX_MULTI_FIELDS_SIZE} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts new file mode 100644 index 0000000000000..4468953a26d17 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts @@ -0,0 +1,474 @@ +/* + * Copyright 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 { CoreStart } from 'kibana/public'; +import type { FrameDatasourceAPI } from '../../../../types'; +import type { CountIndexPatternColumn } from '../index'; +import type { TermsIndexPatternColumn } from './types'; +import type { GenericIndexPatternColumn } from '../../../indexpattern'; +import { createMockedIndexPattern } from '../../../mocks'; +import { + getDisallowedTermsMessage, + getMultiTermsScriptedFieldErrorMessage, + isSortableByColumn, + MULTI_KEY_VISUAL_SEPARATOR, +} from './helpers'; +import { ReferenceBasedIndexPatternColumn } from '../column_types'; + +const indexPattern = createMockedIndexPattern(); + +const coreMock = { + uiSettings: { + get: () => undefined, + }, + http: { + post: jest.fn(() => + Promise.resolve({ + topValues: { + buckets: [ + { + key: 'A', + }, + { + key: 'B', + }, + ], + }, + }) + ), + }, +} as unknown as CoreStart; + +function getStringBasedOperationColumn( + field = 'source', + params?: Partial +): TermsIndexPatternColumn { + return { + label: `Top value of ${field}`, + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + ...params, + }, + sourceField: field, + }; +} + +function getLayer( + col1: TermsIndexPatternColumn = getStringBasedOperationColumn(), + cols?: GenericIndexPatternColumn[] +) { + const colsObject = cols + ? cols.reduce((memo, col, i) => ({ ...memo, [`col${i + 2}`]: col }), {}) + : {}; + return { + indexPatternId: '1', + columnOrder: ['col1', ...Object.keys(colsObject)], + columns: { + col1, + ...colsObject, + }, + }; +} + +function getCountOperationColumn( + params?: Partial +): GenericIndexPatternColumn { + return { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + ...params, + }; +} + +describe('getMultiTermsScriptedFieldErrorMessage()', () => { + it('should return no error message for a single field', () => { + expect( + getMultiTermsScriptedFieldErrorMessage(getLayer(), 'col1', indexPattern) + ).toBeUndefined(); + }); + + it('should return no error message for a scripted field when single', () => { + const col = getStringBasedOperationColumn('scripted'); + expect( + getMultiTermsScriptedFieldErrorMessage(getLayer(col), 'col1', indexPattern) + ).toBeUndefined(); + }); + + it('should return an error message for a scripted field when there are multiple fields', () => { + const col = getStringBasedOperationColumn('scripted', { secondaryFields: ['bytes'] }); + expect(getMultiTermsScriptedFieldErrorMessage(getLayer(col), 'col1', indexPattern)).toBe( + 'Scripted fields are not supported when using multiple fields, found scripted' + ); + }); + + it('should return no error message for multiple "native" fields', () => { + const col = getStringBasedOperationColumn('source', { secondaryFields: ['dest'] }); + expect( + getMultiTermsScriptedFieldErrorMessage(getLayer(col), 'col1', indexPattern) + ).toBeUndefined(); + }); + + it('should list all scripted fields in the error message', () => { + const col = getStringBasedOperationColumn('scripted', { + secondaryFields: ['scripted', 'scripted', 'scripted'], + }); + expect(getMultiTermsScriptedFieldErrorMessage(getLayer(col), 'col1', indexPattern)).toBe( + 'Scripted fields are not supported when using multiple fields, found scripted, scripted, scripted, scripted' + ); + }); +}); + +describe('getDisallowedTermsMessage()', () => { + it('should return no error if no shifted dimensions are defined', () => { + expect(getDisallowedTermsMessage(getLayer(), 'col1', indexPattern)).toBeUndefined(); + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [getCountOperationColumn()]), + 'col1', + indexPattern + ) + ).toBeUndefined(); + }); + + it('should return no error for a single dimension shifted', () => { + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [getCountOperationColumn({ timeShift: '1w' })]), + 'col1', + indexPattern + ) + ).toBeUndefined(); + }); + + it('should return no for multiple fields with no shifted dimensions', () => { + expect(getDisallowedTermsMessage(getLayer(), 'col1', indexPattern)).toBeUndefined(); + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [getCountOperationColumn()]), + 'col1', + indexPattern + ) + ).toBeUndefined(); + }); + + it('should return an error for multiple dimensions shifted for a single term', () => { + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + ) + ).toEqual( + expect.objectContaining({ + message: + 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', + fixAction: expect.objectContaining({ label: 'Use filters' }), + }) + ); + }); + + it('should return an error for multiple dimensions shifted for multiple terms', () => { + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn('source', { secondaryFields: ['bytes'] }), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + ) + ).toEqual( + expect.objectContaining({ + message: + 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', + fixAction: expect.objectContaining({ label: 'Use filters' }), + }) + ); + }); + + it('should propose a fixAction for single term when no data is available', async () => { + const fixAction = getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + )!.fixAction.newState; + const newLayer = await fixAction( + coreMock, + { + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + } as unknown as FrameDatasourceAPI, + 'first' + ); + + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { + input: { + language: 'kuery', + query: 'source: "A"', + }, + label: 'A', + }, + { + input: { + language: 'kuery', + query: 'source: "B"', + }, + label: 'B', + }, + ], + }, + }) + ); + }); + + it('should propose a fixAction for single term when data is available with current top values', async () => { + const fixAction = getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + )!.fixAction.newState; + const newLayer = await fixAction( + coreMock, + { + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + activeData: { + first: { + columns: [{ id: 'col1', meta: { field: 'source' } }], + rows: [{ col1: 'myTerm' }, { col1: 'myOtherTerm' }], + }, + }, + } as unknown as FrameDatasourceAPI, + 'first' + ); + + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { input: { language: 'kuery', query: 'source: "myTerm"' }, label: 'myTerm' }, + { input: { language: 'kuery', query: 'source: "myOtherTerm"' }, label: 'myOtherTerm' }, + ], + }, + }) + ); + }); + + it('should propose a fixAction for multiple term when no data is available', async () => { + const fixAction = getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn('source', { secondaryFields: ['bytes'] }), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + )!.fixAction.newState; + const newLayer = await fixAction( + coreMock, + { + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + } as unknown as FrameDatasourceAPI, + 'first' + ); + + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { + input: { + language: 'kuery', + query: 'source: * AND bytes: *', + }, + label: `source: * ${MULTI_KEY_VISUAL_SEPARATOR} bytes: *`, + }, + ], + }, + }) + ); + }); + + it('should propose a fixAction for multiple term when data is available with current top values', async () => { + const fixAction = getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn('source', { secondaryFields: ['bytes'] }), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + )!.fixAction.newState; + const newLayer = await fixAction( + coreMock, + { + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + activeData: { + first: { + columns: [{ id: 'col1', meta: { field: undefined } }], + rows: [ + { col1: { keys: ['myTerm', '4000'] } }, + { col1: { keys: ['myOtherTerm', '8000'] } }, + ], + }, + }, + } as unknown as FrameDatasourceAPI, + 'first' + ); + + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { + input: { language: 'kuery', query: 'source: "myTerm" AND bytes: "4000"' }, + label: `source: myTerm ${MULTI_KEY_VISUAL_SEPARATOR} bytes: 4000`, + }, + { + input: { language: 'kuery', query: 'source: "myOtherTerm" AND bytes: "8000"' }, + label: `source: myOtherTerm ${MULTI_KEY_VISUAL_SEPARATOR} bytes: 8000`, + }, + ], + }, + }) + ); + }); +}); + +describe('isSortableByColumn()', () => { + it('should sort by the given column', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [getCountOperationColumn()]), + 'col2' + ) + ).toBeTruthy(); + }); + + it('should not be sortable by full-reference columns', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: `Difference of Average of bytes`, + dataType: 'number', + operationType: 'differences', + isBucketed: false, + references: ['colX'], + scale: 'ratio', + }, + ]), + 'col2' + ) + ).toBeFalsy(); + }); + + it('should not be sortable by referenced columns', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: `Difference of Average of bytes`, + dataType: 'number', + operationType: 'differences', + isBucketed: false, + references: ['col3'], + scale: 'ratio', + }, + { + label: 'Average', + dataType: 'number', + isBucketed: false, + sourceField: 'bytes', + operationType: 'average', + }, + ]), + 'col3' + ) + ).toBeFalsy(); + }); + + it('should not be sortable by a managed column', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: 'Static value: 100', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: 100 }, + references: [], + } as ReferenceBasedIndexPatternColumn, + ]), + 'col2' + ) + ).toBeFalsy(); + }); + + it('should not be sortable by a last_value function', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: 'Last Value', + dataType: 'number', + isBucketed: false, + sourceField: 'bytes', + operationType: 'last_value', + params: { + sortField: 'time', + }, + } as GenericIndexPatternColumn, + ]), + 'col2' + ) + ).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts new file mode 100644 index 0000000000000..2917abbf848f8 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts @@ -0,0 +1,215 @@ +/* + * Copyright 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 { uniq } from 'lodash'; +import type { CoreStart } from 'kibana/public'; +import { buildEsQuery } from '@kbn/es-query'; +import { getEsQueryConfig } from '../../../../../../../../src/plugins/data/public'; +import { operationDefinitionMap } from '../index'; +import { defaultLabel } from '../filters'; +import { isReferenced } from '../../layer_helpers'; + +import type { FieldStatsResponse } from '../../../../../common'; +import type { FrameDatasourceAPI } from '../../../../types'; +import type { FiltersIndexPatternColumn } from '../index'; +import type { TermsIndexPatternColumn } from './types'; +import type { IndexPatternLayer, IndexPattern } from '../../../types'; + +export const MULTI_KEY_VISUAL_SEPARATOR = '›'; + +const fullSeparatorString = ` ${MULTI_KEY_VISUAL_SEPARATOR} `; + +export function getMultiTermsScriptedFieldErrorMessage( + layer: IndexPatternLayer, + columnId: string, + indexPattern: IndexPattern +) { + const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn; + const usedFields = [currentColumn.sourceField, ...(currentColumn.params.secondaryFields ?? [])]; + + const scriptedFields = usedFields.filter((field) => indexPattern.getFieldByName(field)?.scripted); + if (usedFields.length < 2 || !scriptedFields.length) { + return; + } + + return i18n.translate('xpack.lens.indexPattern.termsWithMultipleTermsAndScriptedFields', { + defaultMessage: 'Scripted fields are not supported when using multiple fields, found {fields}', + values: { + fields: scriptedFields.join(', '), + }, + }); +} + +function getQueryForMultiTerms(fieldNames: string[], term: string) { + const terms = term.split(fullSeparatorString); + return fieldNames + .map((fieldName, i) => `${fieldName}: ${terms[i] !== '*' ? `"${terms[i]}"` : terms[i]}`) + .join(' AND '); +} + +function getQueryLabel(fieldNames: string[], term: string) { + if (fieldNames.length === 1) { + return term; + } + return term + .split(fullSeparatorString) + .map((t: string, index: number) => { + if (t == null) { + return i18n.translate('xpack.lens.indexPattern.filterBy.emptyFilterQuery', { + defaultMessage: '(empty)', + }); + } + return `${fieldNames[index]}: ${t}`; + }) + .join(fullSeparatorString); +} + +interface MultiFieldKeyFormat { + keys: string[]; +} + +function isMultiFieldValue(term: unknown): term is MultiFieldKeyFormat { + return ( + typeof term === 'object' && + term != null && + 'keys' in term && + Array.isArray((term as MultiFieldKeyFormat).keys) + ); +} + +export function getDisallowedTermsMessage( + layer: IndexPatternLayer, + columnId: string, + indexPattern: IndexPattern +) { + const hasMultipleShifts = + uniq( + Object.values(layer.columns) + .filter((col) => operationDefinitionMap[col.operationType].shiftable) + .map((col) => col.timeShift || '') + ).length > 1; + if (!hasMultipleShifts) { + return undefined; + } + return { + message: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShifts', { + defaultMessage: + 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', + }), + fixAction: { + label: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel', { + defaultMessage: 'Use filters', + }), + newState: async (core: CoreStart, frame: FrameDatasourceAPI, layerId: string) => { + const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn; + const fieldNames = [ + currentColumn.sourceField, + ...(currentColumn.params?.secondaryFields ?? []), + ]; + const activeDataFieldNameMatch = + frame.activeData?.[layerId].columns.find(({ id }) => id === columnId)?.meta.field === + fieldNames[0]; + + let currentTerms = uniq( + frame.activeData?.[layerId].rows + .map((row) => row[columnId] as string | MultiFieldKeyFormat) + .filter((term) => + fieldNames.length > 1 + ? isMultiFieldValue(term) && term.keys[0] !== '__other__' + : typeof term === 'string' && term !== '__other__' + ) + .map((term: string | MultiFieldKeyFormat) => + isMultiFieldValue(term) ? term.keys.join(fullSeparatorString) : term + ) || [] + ); + if (!activeDataFieldNameMatch || currentTerms.length === 0) { + if (fieldNames.length === 1) { + const response: FieldStatsResponse = await core.http.post( + `/api/lens/index_stats/${indexPattern.id}/field`, + { + body: JSON.stringify({ + fieldName: fieldNames[0], + dslQuery: buildEsQuery( + indexPattern, + frame.query, + frame.filters, + getEsQueryConfig(core.uiSettings) + ), + fromDate: frame.dateRange.fromDate, + toDate: frame.dateRange.toDate, + size: currentColumn.params.size, + }), + } + ); + currentTerms = response.topValues?.buckets.map(({ key }) => String(key)) || []; + } + } + // when multi terms the meta.field will always be undefined, so limit the check to no data + if (fieldNames.length > 1 && currentTerms.length === 0) { + // this will produce a query like `field1: * AND field2: * ...etc` + // which is the best we can do for multiple terms when no data is available + currentTerms = [Array(fieldNames.length).fill('*').join(fullSeparatorString)]; + } + + return { + ...layer, + columns: { + ...layer.columns, + [columnId]: { + label: i18n.translate('xpack.lens.indexPattern.pinnedTopValuesLabel', { + defaultMessage: 'Filters of {field}', + values: { + field: + fieldNames.length > 1 ? fieldNames.join(fullSeparatorString) : fieldNames[0], + }, + }), + customLabel: true, + isBucketed: layer.columns[columnId].isBucketed, + dataType: 'string', + operationType: 'filters', + params: { + filters: + currentTerms.length > 0 + ? currentTerms.map((term) => ({ + input: { + query: + fieldNames.length === 1 + ? `${fieldNames[0]}: "${term}"` + : getQueryForMultiTerms(fieldNames, term), + language: 'kuery', + }, + label: getQueryLabel(fieldNames, term), + })) + : [ + { + input: { + query: '*', + language: 'kuery', + }, + label: defaultLabel, + }, + ], + }, + } as FiltersIndexPatternColumn, + }, + }; + }, + }, + }; +} + +export function isSortableByColumn(layer: IndexPatternLayer, columnId: string) { + const column = layer.columns[columnId]; + return ( + column && + !column.isBucketed && + column.operationType !== 'last_value' && + !('references' in column) && + !isReferenced(layer, columnId) + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 78894274db168..f84664ccc32d8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -16,168 +16,53 @@ import { EuiAccordion, EuiIconTip, } from '@elastic/eui'; -import { uniq } from 'lodash'; -import { CoreStart } from 'kibana/public'; -import { buildEsQuery } from '@kbn/es-query'; -import { FieldStatsResponse } from '../../../../../common'; -import { - AggFunctionsMapping, - getEsQueryConfig, -} from '../../../../../../../../src/plugins/data/public'; +import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public'; -import { updateColumnParam, isReferenced } from '../../layer_helpers'; -import { DataType, FrameDatasourceAPI } from '../../../../types'; -import { FiltersIndexPatternColumn, OperationDefinition, operationDefinitionMap } from '../index'; +import { updateColumnParam } from '../../layer_helpers'; +import type { DataType } from '../../../../types'; +import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; import { ValuesInput } from './values_input'; import { getInvalidFieldMessage } from '../helpers'; -import type { IndexPatternLayer, IndexPattern } from '../../../types'; -import { defaultLabel } from '../filters'; +import { FieldInputs, MAX_MULTI_FIELDS_SIZE } from './field_inputs'; +import { + FieldInput as FieldInputBase, + getErrorMessage, +} from '../../../dimension_panel/field_input'; +import type { TermsIndexPatternColumn } from './types'; +import { + getDisallowedTermsMessage, + getMultiTermsScriptedFieldErrorMessage, + isSortableByColumn, +} from './helpers'; + +export type { TermsIndexPatternColumn } from './types'; -function ofName(name?: string) { +const missingFieldLabel = i18n.translate('xpack.lens.indexPattern.missingFieldLabel', { + defaultMessage: 'Missing field', +}); + +function ofName(name?: string, count: number = 0) { + if (count) { + return i18n.translate('xpack.lens.indexPattern.multipleTermsOf', { + defaultMessage: 'Top values of {name} + {count} {count, plural, one {other} other {others}}', + values: { + name: name ?? missingFieldLabel, + count, + }, + }); + } return i18n.translate('xpack.lens.indexPattern.termsOf', { defaultMessage: 'Top values of {name}', values: { - name: - name ?? - i18n.translate('xpack.lens.indexPattern.missingFieldLabel', { - defaultMessage: 'Missing field', - }), + name: name ?? missingFieldLabel, }, }); } -function isSortableByColumn(layer: IndexPatternLayer, columnId: string) { - const column = layer.columns[columnId]; - return ( - column && - !column.isBucketed && - column.operationType !== 'last_value' && - !('references' in column) && - !isReferenced(layer, columnId) - ); -} - -function getDisallowedTermsMessage( - layer: IndexPatternLayer, - columnId: string, - indexPattern: IndexPattern -) { - const hasMultipleShifts = - uniq( - Object.values(layer.columns) - .filter((col) => operationDefinitionMap[col.operationType].shiftable) - .map((col) => col.timeShift || '') - ).length > 1; - if (!hasMultipleShifts) { - return undefined; - } - return { - message: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShifts', { - defaultMessage: - 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', - }), - fixAction: { - label: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel', { - defaultMessage: 'Use filters', - }), - newState: async (core: CoreStart, frame: FrameDatasourceAPI, layerId: string) => { - const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn; - const fieldName = currentColumn.sourceField; - const activeDataFieldNameMatch = - frame.activeData?.[layerId].columns.find(({ id }) => id === columnId)?.meta.field === - fieldName; - let currentTerms = uniq( - frame.activeData?.[layerId].rows - .map((row) => row[columnId] as string) - .filter((term) => typeof term === 'string' && term !== '__other__') || [] - ); - if (!activeDataFieldNameMatch || currentTerms.length === 0) { - const response: FieldStatsResponse = await core.http.post( - `/api/lens/index_stats/${indexPattern.id}/field`, - { - body: JSON.stringify({ - fieldName, - dslQuery: buildEsQuery( - indexPattern, - frame.query, - frame.filters, - getEsQueryConfig(core.uiSettings) - ), - fromDate: frame.dateRange.fromDate, - toDate: frame.dateRange.toDate, - size: currentColumn.params.size, - }), - } - ); - currentTerms = response.topValues?.buckets.map(({ key }) => String(key)) || []; - } - return { - ...layer, - columns: { - ...layer.columns, - [columnId]: { - label: i18n.translate('xpack.lens.indexPattern.pinnedTopValuesLabel', { - defaultMessage: 'Filters of {field}', - values: { - field: fieldName, - }, - }), - customLabel: true, - isBucketed: layer.columns[columnId].isBucketed, - dataType: 'string', - operationType: 'filters', - params: { - filters: - currentTerms.length > 0 - ? currentTerms.map((term) => ({ - input: { - query: `${fieldName}: "${term}"`, - language: 'kuery', - }, - label: term, - })) - : [ - { - input: { - query: '*', - language: 'kuery', - }, - label: defaultLabel, - }, - ], - }, - } as FiltersIndexPatternColumn, - }, - }; - }, - }, - }; -} - const DEFAULT_SIZE = 3; const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']); -export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { - operationType: 'terms'; - params: { - size: number; - // if order is alphabetical, the `fallback` flag indicates whether it became alphabetical because there wasn't - // another option or whether the user explicitly chose to make it alphabetical. - orderBy: { type: 'alphabetical'; fallback?: boolean } | { type: 'column'; columnId: string }; - orderDirection: 'asc' | 'desc'; - otherBucket?: boolean; - missingBucket?: boolean; - // Terms on numeric fields can be formatted - format?: { - id: string; - params?: { - decimals: number; - }; - }; - }; -} - export const termsOperation: OperationDefinition = { type: 'terms', displayName: i18n.translate('xpack.lens.indexPattern.terms', { @@ -185,6 +70,12 @@ export const termsOperation: OperationDefinition { + return (column.params?.secondaryFields?.length ?? 0) < MAX_MULTI_FIELDS_SIZE; + }, + getDefaultVisualSettings: (column) => ({ + truncateText: Boolean(!column.params?.secondaryFields?.length), + }), getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( supportedTypes.has(type) && @@ -201,6 +92,7 @@ export const termsOperation: OperationDefinition { + if (column.params?.secondaryFields?.length) { + return buildExpressionFunction('aggMultiTerms', { + id: columnId, + enabled: true, + schema: 'segment', + fields: [column.sourceField, ...column.params.secondaryFields], + orderBy: + column.params.orderBy.type === 'alphabetical' + ? '_key' + : String(orderedColumnIds.indexOf(column.params.orderBy.columnId)), + order: column.params.orderDirection, + size: column.params.size, + otherBucket: Boolean(column.params.otherBucket), + otherBucketLabel: i18n.translate('xpack.lens.indexPattern.terms.otherLabel', { + defaultMessage: 'Other', + }), + }).toAst(); + } return buildExpressionFunction('aggTerms', { id: columnId, enabled: true, @@ -268,9 +178,13 @@ export const termsOperation: OperationDefinition - ofName(indexPattern.getFieldByName(column.sourceField)?.displayName), + ofName( + indexPattern.getFieldByName(column.sourceField)?.displayName, + column.params.secondaryFields?.length + ), onFieldChange: (oldColumn, field) => { - const newParams = { ...oldColumn.params }; + // reset the secondary fields + const newParams = { ...oldColumn.params, secondaryFields: undefined }; if ('format' in newParams && field.type !== 'number') { delete newParams.format; } @@ -314,6 +228,89 @@ export const termsOperation: OperationDefinition { + const column = layer.columns[columnId] as TermsIndexPatternColumn; + updateLayer({ + ...layer, + columns: { + ...layer.columns, + [columnId]: { + ...column, + sourceField: fields[0], + label: ofName(indexPattern.getFieldByName(fields[0])?.displayName, fields.length - 1), + params: { + ...column.params, + secondaryFields: fields.length > 1 ? fields.slice(1) : undefined, + }, + }, + } as Record, + }); + }, + [columnId, indexPattern, layer, updateLayer] + ); + const currentColumn = layer.columns[columnId]; + + const fieldErrorMessage = getErrorMessage( + selectedColumn, + Boolean(props.incompleteOperation), + 'field', + props.currentFieldIsInvalid + ); + + // let the default component do its job in case of incomplete informations + if ( + !currentColumn || + !selectedColumn || + props.incompleteOperation || + (fieldErrorMessage && !selectedColumn.params?.secondaryFields?.length) + ) { + return ; + } + + const showScriptedFieldError = Boolean( + getMultiTermsScriptedFieldErrorMessage(layer, columnId, indexPattern) + ); + + return ( + + + + ); + }, paramEditor: function ParamEditor({ layer, updateLayer, currentColumn, columnId, indexPattern }) { const hasRestrictions = indexPattern.hasRestrictions; @@ -350,6 +347,7 @@ export const termsOperation: OperationDefinition { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + htmlIdGenerator: (fn: unknown) => { + let counter = 0; + return () => counter++; + }, + }; +}); const uiSettingsMock = {} as IUiSettingsClient; @@ -45,6 +59,7 @@ const defaultProps = { describe('terms', () => { let layer: IndexPatternLayer; const InlineOptions = termsOperation.paramEditor!; + const InlineFieldInput = termsOperation.renderFieldInput!; beforeEach(() => { layer = { @@ -173,6 +188,30 @@ describe('terms', () => { expect(column).toHaveProperty('sourceField', 'source'); expect(column.params.format).toBeUndefined(); }); + + it('should remove secondary fields when a new field is passed', () => { + const oldColumn: TermsIndexPatternColumn = { + operationType: 'terms', + sourceField: 'bytes', + label: 'Top values of bytes', + isBucketed: true, + dataType: 'number', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + format: { id: 'number', params: { decimals: 0 } }, + secondaryFields: ['dest'], + }, + }; + const indexPattern = createMockedIndexPattern(); + const newStringField = indexPattern.fields.find((i) => i.name === 'source')!; + + const column = termsOperation.onFieldChange(oldColumn, newStringField); + expect(column.params.secondaryFields).toBeUndefined(); + }); }); describe('getPossibleOperationForField', () => { @@ -686,6 +725,575 @@ describe('terms', () => { }); }); + describe('getDefaultLabel', () => { + it('should return the default label for single value', () => { + expect( + termsOperation.getDefaultLabel( + { + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical', fallback: true }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'source', + } as TermsIndexPatternColumn, + createMockedIndexPattern(), + {} + ) + ).toBe('Top values of source'); + }); + + it('should return main value with single counter for two fields', () => { + expect( + termsOperation.getDefaultLabel( + { + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical', fallback: true }, + size: 3, + orderDirection: 'asc', + secondaryFields: ['bytes'], + }, + sourceField: 'source', + } as TermsIndexPatternColumn, + createMockedIndexPattern(), + {} + ) + ).toBe('Top values of source + 1 other'); + }); + + it('should return main value with counter value for multiple values', () => { + expect( + termsOperation.getDefaultLabel( + { + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical', fallback: true }, + size: 3, + orderDirection: 'asc', + secondaryFields: ['bytes', 'memory'], + }, + sourceField: 'source', + } as TermsIndexPatternColumn, + createMockedIndexPattern(), + {} + ) + ).toBe('Top values of source + 2 others'); + }); + }); + + describe('field input', () => { + // @ts-expect-error + window['__react-beautiful-dnd-disable-dev-warnings'] = true; // issue with enzyme & react-beautiful-dnd throwing errors: https://github.com/atlassian/react-beautiful-dnd/issues/1593 + + const defaultFieldInputProps = { + indexPattern: defaultProps.indexPattern, + currentFieldIsInvalid: false, + incompleteField: null, + incompleteOperation: undefined, + incompleteParams: {}, + dimensionGroups: [], + groupId: 'any', + operationDefinitionMap: { terms: termsOperation } as unknown as Record< + string, + GenericOperationDefinition + >, + }; + + function getExistingFields() { + const fields: Record = {}; + for (const field of defaultProps.indexPattern.fields) { + fields[field.name] = true; + } + return { + [layer.indexPatternId]: fields, + }; + } + + function getDefaultOperationSupportMatrix( + columnId: string, + existingFields: Record> + ) { + return getOperationSupportMatrix({ + state: { + layers: { layer1: layer }, + indexPatterns: { + [defaultProps.indexPattern.id]: defaultProps.indexPattern, + }, + existingFields, + } as unknown as IndexPatternPrivateState, + layerId: 'layer1', + filterOperations: () => true, + columnId, + }); + } + + it('should render the default field input for no field (incomplete operation)', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const instance = mount( + + ); + + // Fallback field input has no add button + expect(instance.find('[data-test-subj="indexPattern-terms-add-field"]').exists()).toBeFalsy(); + // check the error state too + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeTruthy(); + }); + + it('should show an error message when field is invalid', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + layer.columns.col1 = { + label: 'Top value of unsupported', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'unsupported', + } as TermsIndexPatternColumn; + const instance = mount( + + ); + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('Invalid field. Check your data view or pick another field.'); + }); + + it('should show an error message when field is not supported', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + layer.columns.col1 = { + label: 'Top value of timestamp', + dataType: 'date', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'timestamp', + } as TermsIndexPatternColumn; + const instance = mount( + + ); + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('This field does not work with the selected function.'); + }); + + it('should render the an add button for single layer, but no other hints', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').exists() + ).toBeTruthy(); + + expect(instance.find('[data-test-subj^="indexPattern-terms-removeField-"]').length).toBe(0); + }); + + it('should render the multi terms specific UI', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['bytes']; + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').exists() + ).toBeTruthy(); + // the produced Enzyme DOM has the both the React component and the actual html + // tags with the same "data-test-subj" assigned. Here it is enough to check that multiple are rendered + expect( + instance.find('[data-test-subj^="indexPattern-terms-removeField-"]').length + ).toBeGreaterThan(1); + expect( + instance.find('[data-test-subj^="indexPattern-terms-dragToReorder-"]').length + ).toBeGreaterThan(1); + }); + + it('should return to single value UI when removing second item of two', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj^="indexPattern-terms-removeField-"]').length + ).toBeGreaterThan(1); + + act(() => { + instance + .find('[data-test-subj="indexPattern-terms-removeField-1"]') + .first() + .simulate('click'); + }); + + expect(instance.find('[data-test-subj="indexPattern-terms-removeField-"]').length).toBe(0); + }); + + it('should disable remove button and reorder drag when single value and one temporary new field', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + let instance = mount( + + ); + + // add a new field + act(() => { + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().simulate('click'); + }); + + instance = instance.update(); + // now two delete buttons should be visualized + expect(instance.find('[data-test-subj="indexPattern-terms-removeField-1"]').exists()).toBe( + true + ); + // first button is disabled + expect( + instance + .find('[data-test-subj="indexPattern-terms-removeField-0"]') + .first() + .prop('isDisabled') + ).toBe(true); + // while second delete is still enabled + expect( + instance + .find('[data-test-subj="indexPattern-terms-removeField-1"]') + .first() + .prop('isDisabled') + ).toBe(false); + }); + + it('should accept scripted fields for single value', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).sourceField = 'scripted'; + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeFalsy(); + }); + + it('should mark scripted fields for multiple values', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).sourceField = 'scripted'; + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeTruthy(); + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('Scripted fields are not supported when using multiple fields'); + }); + + it('should not filter scripted fields when in single value', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-dimension-field"]').first().prop('options') + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ 'data-test-subj': 'lns-fieldOption-scripted' }), + ]), + }), + ]) + ); + }); + + it('should filter scripted fields when in multi terms mode', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; + const instance = mount( + + ); + + // get inner instance + expect( + instance.find('[data-test-subj="indexPattern-dimension-field-0"]').at(1).prop('options') + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + options: expect.arrayContaining([ + expect.not.objectContaining({ 'data-test-subj': 'lns-fieldOption-scripted' }), + ]), + }), + ]) + ); + }); + + it('should filter already used fields when displaying fields list', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory', 'bytes']; + let instance = mount( + + ); + + // add a new field + act(() => { + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().simulate('click'); + }); + + instance = instance.update(); + + // Get the inner instance with the data-test-subj + expect( + instance.find('[data-test-subj="indexPattern-dimension-field-3"]').at(1).prop('options') + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + options: expect.not.arrayContaining([ + expect.objectContaining({ label: 'memory' }), + expect.objectContaining({ label: 'bytes' }), + ]), + }), + ]) + ); + }); + + it('should limit the number of multiple fields', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = [ + 'memory', + 'bytes', + 'dest', + ]; + let instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().prop('isDisabled') + ).toBeTruthy(); + // clicking again will no increase the number of fields + act(() => { + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().simulate('click'); + }); + instance = instance.update(); + expect( + instance.find('[data-test-subj="indexPattern-terms-removeField-4"]').exists() + ).toBeFalsy(); + }); + + it('should let the user add new empty field up to the limit', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + let instance = mount( + + ); + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().prop('isDisabled') + ).toBeFalsy(); + + // click 3 times to add new fields + for (const _ of [1, 2, 3]) { + act(() => { + instance + .find('[data-test-subj="indexPattern-terms-add-field"]') + .first() + .simulate('click'); + }); + instance = instance.update(); + } + + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + }); + describe('param editor', () => { it('should render current other bucket value', () => { const updateLayerSpy = jest.fn(); @@ -1043,6 +1651,39 @@ describe('terms', () => { ]); }); + it('return no error for scripted field when in single mode', () => { + layer = { + ...layer, + columns: { + col1: { + ...layer.columns.col1, + sourceField: 'scripted', + } as TermsIndexPatternColumn, + }, + }; + expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toBeUndefined(); + }); + + it('return error for scripted field when in multi terms mode', () => { + const column = layer.columns.col1 as TermsIndexPatternColumn; + layer = { + ...layer, + columns: { + col1: { + ...column, + sourceField: 'scripted', + params: { + ...column.params, + secondaryFields: ['bytes'], + }, + } as TermsIndexPatternColumn, + }, + }; + expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([ + 'Scripted fields are not supported when using multiple fields, found scripted', + ]); + }); + describe('time shift error', () => { beforeEach(() => { layer = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts new file mode 100644 index 0000000000000..a1b61880ade3f --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FieldBasedIndexPatternColumn } from '../column_types'; + +export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'terms'; + params: { + size: number; + // if order is alphabetical, the `fallback` flag indicates whether it became alphabetical because there wasn't + // another option or whether the user explicitly chose to make it alphabetical. + orderBy: { type: 'alphabetical'; fallback?: boolean } | { type: 'column'; columnId: string }; + orderDirection: 'asc' | 'desc'; + otherBucket?: boolean; + missingBucket?: boolean; + secondaryFields?: string[]; + // Terms on numeric fields can be formatted + format?: { + id: string; + params?: { + decimals: number; + }; + }; + parentFormat?: { + id: string; + params?: { + id?: string; + template?: string; + }; + }; + }; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts new file mode 100644 index 0000000000000..a265b27c1dd68 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.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 type { DataType } from '../types'; +import type { DraggedField } from './types'; +import type { + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './operations/definitions/column_types'; + +/** + * Normalizes the specified operation type. (e.g. document operations + * produce 'number') + */ +export function normalizeOperationDataType(type: DataType) { + if (type === 'histogram') return 'number'; + return type === 'document' ? 'number' : type; +} + +export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function sortByField(columns: C[]) { + return [...columns].sort((column1, column2) => { + if (hasField(column1) && hasField(column2)) { + return column1.sourceField.localeCompare(column2.sourceField); + } + return column1.operationType.localeCompare(column2.operationType); + }); +} + +export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { + return ( + typeof fieldCandidate === 'object' && + fieldCandidate !== null && + ['id', 'field', 'indexPatternId'].every((prop) => prop in fieldCandidate) + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx index 6baba7e19716a..76156b5a57a11 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx @@ -11,54 +11,16 @@ import type { DocLinksStart } from 'kibana/public'; import { EuiLink, EuiTextColor } from '@elastic/eui'; import { DatatableColumn } from 'src/plugins/expressions'; -import type { DataType, FramePublicAPI } from '../types'; -import type { - IndexPattern, - IndexPatternLayer, - DraggedField, - IndexPatternPrivateState, -} from './types'; -import type { - BaseIndexPatternColumn, - FieldBasedIndexPatternColumn, - ReferenceBasedIndexPatternColumn, -} from './operations/definitions/column_types'; +import type { FramePublicAPI } from '../types'; +import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from './types'; +import type { ReferenceBasedIndexPatternColumn } from './operations/definitions/column_types'; import { operationDefinitionMap, GenericIndexPatternColumn } from './operations'; import { getInvalidFieldMessage } from './operations/definitions/helpers'; import { isQueryValid } from './operations/definitions/filters'; import { checkColumnForPrecisionError } from '../../../../../src/plugins/data/common'; - -/** - * Normalizes the specified operation type. (e.g. document operations - * produce 'number') - */ -export function normalizeOperationDataType(type: DataType) { - if (type === 'histogram') return 'number'; - return type === 'document' ? 'number' : type; -} - -export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { - return 'sourceField' in column; -} - -export function sortByField(columns: C[]) { - return [...columns].sort((column1, column2) => { - if (hasField(column1) && hasField(column2)) { - return column1.sourceField.localeCompare(column2.sourceField); - } - return column1.operationType.localeCompare(column2.operationType); - }); -} - -export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { - return ( - typeof fieldCandidate === 'object' && - fieldCandidate !== null && - ['id', 'field', 'indexPatternId'].every((prop) => prop in fieldCandidate) - ); -} +import { hasField } from './pure_utils'; export function isColumnInvalid( layer: IndexPatternLayer, @@ -171,3 +133,20 @@ export function getPrecisionErrorWarningMessages( return warningMessages; } + +export function getVisualDefaultsForLayer(layer: IndexPatternLayer) { + return Object.keys(layer.columns).reduce>>( + (memo, columnId) => { + const column = layer.columns[columnId]; + if (column?.operationType) { + const opDefinition = operationDefinitionMap[column.operationType]; + const params = opDefinition.getDefaultVisualSettings?.(column); + if (params) { + memo[columnId] = params; + } + } + return memo; + }, + {} + ); +} diff --git a/x-pack/plugins/lens/public/lens_attribute_service.ts b/x-pack/plugins/lens/public/lens_attribute_service.ts index 80bdb8ce737b0..d0539b99b8eab 100644 --- a/x-pack/plugins/lens/public/lens_attribute_service.ts +++ b/x-pack/plugins/lens/public/lens_attribute_service.ts @@ -15,8 +15,8 @@ import type { LensUnwrapResult, LensByReferenceInput, } from './embeddable/embeddable'; -import { SavedObjectIndexStore } from './persistence'; -import { checkForDuplicateTitle, OnSaveProps } from '../../../../src/plugins/saved_objects/public'; +import { SavedObjectIndexStore, checkForDuplicateTitle } from './persistence'; +import { OnSaveProps } from '../../../../src/plugins/saved_objects/public'; import { DOC_TYPE } from '../common/constants'; export type LensAttributeService = AttributeService< diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index 2614b1d5fdc94..50df6f07cb5dc 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -16,6 +16,7 @@ export function createMockDatasource(id: string): DatasourceMock { datasourceId: id, getTableSpec: jest.fn(() => []), getOperationForColumnId: jest.fn(), + getVisualDefaults: jest.fn(), }; return { diff --git a/x-pack/plugins/lens/public/persistence/index.ts b/x-pack/plugins/lens/public/persistence/index.ts index 66f75aed35fcc..0fd3388ef416a 100644 --- a/x-pack/plugins/lens/public/persistence/index.ts +++ b/x-pack/plugins/lens/public/persistence/index.ts @@ -7,3 +7,4 @@ export * from './saved_object_store'; export * from './filter_references'; +export { checkForDuplicateTitle } from './saved_objects_utils'; diff --git a/x-pack/plugins/lens/public/persistence/saved_objects_utils/check_for_duplicate_title.ts b/x-pack/plugins/lens/public/persistence/saved_objects_utils/check_for_duplicate_title.ts new file mode 100644 index 0000000000000..d9d29a6fdb2d7 --- /dev/null +++ b/x-pack/plugins/lens/public/persistence/saved_objects_utils/check_for_duplicate_title.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { OverlayStart, SavedObjectsClientContract } from 'kibana/public'; +import type { SavedObject } from 'src/plugins/saved_objects/public'; +import { SAVE_DUPLICATE_REJECTED } from './constants'; +import { findObjectByTitle } from './find_object_by_title'; +import { displayDuplicateTitleConfirmModal } from './display_duplicate_title_confirm_modal'; + +/** + * check for an existing SavedObject with the same title in ES + * returns Promise when it's no duplicate, or the modal displaying the warning + * that's there's a duplicate is confirmed, else it returns a rejected Promise + */ +export async function checkForDuplicateTitle( + savedObject: Pick< + SavedObject, + 'id' | 'title' | 'getDisplayName' | 'lastSavedTitle' | 'copyOnSave' | 'getEsType' + >, + isTitleDuplicateConfirmed: boolean, + onTitleDuplicate: (() => void) | undefined, + services: { savedObjectsClient: SavedObjectsClientContract; overlays: OverlayStart } +): Promise { + const { savedObjectsClient, overlays } = services; + + // Don't check for duplicates if user has already confirmed save with duplicate title + if (isTitleDuplicateConfirmed) { + return true; + } + + // Don't check if the user isn't updating the title, otherwise that would become very annoying to have + // to confirm the save every time, except when copyOnSave is true, then we do want to check. + if (savedObject.title === savedObject.lastSavedTitle && !savedObject.copyOnSave) { + return true; + } + + const duplicate = await findObjectByTitle( + savedObjectsClient, + savedObject.getEsType(), + savedObject.title + ); + + if (!duplicate || duplicate.id === savedObject.id) { + return true; + } + + if (onTitleDuplicate) { + onTitleDuplicate(); + return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED)); + } + + // TODO: make onTitleDuplicate a required prop and remove UI components from this class + // Need to leave here until all users pass onTitleDuplicate. + return displayDuplicateTitleConfirmModal(savedObject, overlays); +} diff --git a/x-pack/plugins/lens/public/persistence/saved_objects_utils/confirm_modal_promise.tsx b/x-pack/plugins/lens/public/persistence/saved_objects_utils/confirm_modal_promise.tsx new file mode 100644 index 0000000000000..120be6b66b339 --- /dev/null +++ b/x-pack/plugins/lens/public/persistence/saved_objects_utils/confirm_modal_promise.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 type { OverlayStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { EuiConfirmModal } from '@elastic/eui'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; + +export function confirmModalPromise( + message = '', + title = '', + confirmBtnText = '', + overlays: OverlayStart +): Promise { + return new Promise((resolve, reject) => { + const cancelButtonText = i18n.translate('xpack.lens.confirmModal.cancelButtonLabel', { + defaultMessage: 'Cancel', + }); + + const modal = overlays.openModal( + toMountPoint( + { + modal.close(); + reject(); + }} + onConfirm={() => { + modal.close(); + resolve(true); + }} + confirmButtonText={confirmBtnText} + cancelButtonText={cancelButtonText} + title={title} + > + {message} + + ) + ); + }); +} diff --git a/x-pack/plugins/lens/public/persistence/saved_objects_utils/constants.ts b/x-pack/plugins/lens/public/persistence/saved_objects_utils/constants.ts new file mode 100644 index 0000000000000..8b4e26d2ed8e5 --- /dev/null +++ b/x-pack/plugins/lens/public/persistence/saved_objects_utils/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +/** An error message to be used when the user rejects a confirm save with duplicate title. */ +export const SAVE_DUPLICATE_REJECTED = i18n.translate( + 'xpack.lens.saveDuplicateRejectedDescription', + { + defaultMessage: 'Save with duplicate title confirmation was rejected', + } +); diff --git a/x-pack/plugins/lens/public/persistence/saved_objects_utils/display_duplicate_title_confirm_modal.ts b/x-pack/plugins/lens/public/persistence/saved_objects_utils/display_duplicate_title_confirm_modal.ts new file mode 100644 index 0000000000000..f40224e42923c --- /dev/null +++ b/x-pack/plugins/lens/public/persistence/saved_objects_utils/display_duplicate_title_confirm_modal.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { OverlayStart } from 'kibana/public'; +import type { SavedObject } from 'src/plugins/saved_objects/public'; +import { SAVE_DUPLICATE_REJECTED } from './constants'; +import { confirmModalPromise } from './confirm_modal_promise'; + +export function displayDuplicateTitleConfirmModal( + savedObject: Pick, + overlays: OverlayStart +): Promise { + const confirmMessage = i18n.translate( + 'xpack.lens.confirmModal.saveDuplicateConfirmationMessage', + { + defaultMessage: `A {name} with the title '{title}' already exists. Would you like to save anyway?`, + values: { title: savedObject.title, name: savedObject.getDisplayName() }, + } + ); + + const confirmButtonText = i18n.translate('xpack.lens.confirmModal.saveDuplicateButtonLabel', { + defaultMessage: 'Save {name}', + values: { name: savedObject.getDisplayName() }, + }); + try { + return confirmModalPromise(confirmMessage, '', confirmButtonText, overlays); + } catch { + return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED)); + } +} diff --git a/x-pack/plugins/lens/public/persistence/saved_objects_utils/find_object_by_title.test.ts b/x-pack/plugins/lens/public/persistence/saved_objects_utils/find_object_by_title.test.ts new file mode 100644 index 0000000000000..72d8dcafcf348 --- /dev/null +++ b/x-pack/plugins/lens/public/persistence/saved_objects_utils/find_object_by_title.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { findObjectByTitle } from './find_object_by_title'; +import { SimpleSavedObject, SavedObjectsClientContract, SavedObject } from 'src/core/public'; + +describe('findObjectByTitle', () => { + const savedObjectsClient: SavedObjectsClientContract = {} as SavedObjectsClientContract; + + beforeEach(() => { + savedObjectsClient.find = jest.fn(); + }); + + it('returns undefined if title is not provided', async () => { + const match = await findObjectByTitle(savedObjectsClient, 'index-pattern', ''); + expect(match).toBeUndefined(); + }); + + it('matches any case', async () => { + const indexPattern = new SimpleSavedObject(savedObjectsClient, { + attributes: { title: 'foo' }, + } as SavedObject); + savedObjectsClient.find = jest.fn().mockImplementation(() => + Promise.resolve({ + savedObjects: [indexPattern], + }) + ); + const match = await findObjectByTitle(savedObjectsClient, 'index-pattern', 'FOO'); + expect(match).toEqual(indexPattern); + }); +}); diff --git a/x-pack/plugins/lens/public/persistence/saved_objects_utils/find_object_by_title.ts b/x-pack/plugins/lens/public/persistence/saved_objects_utils/find_object_by_title.ts new file mode 100644 index 0000000000000..93c86eeb5f3b0 --- /dev/null +++ b/x-pack/plugins/lens/public/persistence/saved_objects_utils/find_object_by_title.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + SavedObjectsClientContract, + SimpleSavedObject, + SavedObjectAttributes, +} from 'kibana/public'; + +/** Returns an object matching a given title */ +export async function findObjectByTitle( + savedObjectsClient: SavedObjectsClientContract, + type: string, + title: string +): Promise | void> { + if (!title) { + return; + } + + // Elastic search will return the most relevant results first, which means exact matches should come + // first, and so we shouldn't need to request everything. Using 10 just to be on the safe side. + const response = await savedObjectsClient.find({ + type, + perPage: 10, + search: `"${title}"`, + searchFields: ['title'], + fields: ['title'], + }); + return response.savedObjects.find( + (obj) => obj.get('title').toLowerCase() === title.toLowerCase() + ); +} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_preview_index.tsx b/x-pack/plugins/lens/public/persistence/saved_objects_utils/index.ts similarity index 58% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_preview_index.tsx rename to x-pack/plugins/lens/public/persistence/saved_objects_utils/index.ts index 7a35e35acefe8..37f36f53951c7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_preview_index.tsx +++ b/x-pack/plugins/lens/public/persistence/saved_objects_utils/index.ts @@ -5,11 +5,4 @@ * 2.0. */ -import { useEffect } from 'react'; -import { createPreviewIndex } from './api'; - -export const usePreviewIndex = () => { - useEffect(() => { - createPreviewIndex(); - }, []); -}; +export { checkForDuplicateTitle } from './check_for_duplicate_title'; diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index 57270337e67a4..f4c951cece3c2 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -12,6 +12,7 @@ import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { shouldShowValuesInLegend } from './render_helpers'; import type { PieVisualizationState } from '../../common/expressions'; +import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; export function toExpression( state: PieVisualizationState, @@ -65,7 +66,10 @@ function expressionHelper( : layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS, ], legendMaxLines: [layer.legendMaxLines ?? 1], - truncateLegend: [layer.truncateLegend ?? true], + truncateLegend: [ + layer.truncateLegend ?? + getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, + ], nestedLegend: [!!layer.nestedLegend], ...(state.palette ? { diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 70ad4d8c07daa..997ebb2e3787f 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -23,6 +23,7 @@ import type { PieVisualizationState, SharedPieLayerState } from '../../common/ex import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components'; import { PalettePicker } from '../shared_components'; +import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; import { shouldShowValuesInLegend } from './render_helpers'; const legendOptions: Array<{ @@ -54,7 +55,7 @@ const legendOptions: Array<{ ]; export function PieToolbar(props: VisualizationToolbarProps) { - const { state, setState } = props; + const { state, setState, frame } = props; const layer = state.layers[0]; const onStateChange = useCallback( @@ -126,6 +127,11 @@ export function PieToolbar(props: VisualizationToolbarProps { + it('should return an object with default values for an empty state', () => { + expect(getDefaultVisualValuesForLayer(undefined, {})).toEqual({ truncateText: true }); + }); + + it('should return true if the layer does not have any default for truncation', () => { + const mockDatasource = createMockDatasource('first'); + expect( + getDefaultVisualValuesForLayer({ layerId: 'first' }, { first: mockDatasource.publicAPIMock }) + ).toEqual({ truncateText: true }); + }); + + it('should prioritize layer settings to default ones ', () => { + const mockDatasource = createMockDatasource('first'); + mockDatasource.publicAPIMock.getVisualDefaults = jest.fn(() => ({ + col1: { truncateText: false }, + })); + expect( + getDefaultVisualValuesForLayer({ layerId: 'first' }, { first: mockDatasource.publicAPIMock }) + ).toEqual({ truncateText: false }); + }); + + it('should give priority to first layer', () => { + const mockDatasource = createMockDatasource('first'); + mockDatasource.publicAPIMock.getVisualDefaults = jest.fn(() => ({ + col1: { truncateText: false }, + col2: { truncateText: true }, + })); + expect( + getDefaultVisualValuesForLayer({ layerId: 'first' }, { first: mockDatasource.publicAPIMock }) + ).toEqual({ truncateText: false }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/datasource_default_values.ts b/x-pack/plugins/lens/public/shared_components/datasource_default_values.ts new file mode 100644 index 0000000000000..250c5614fe093 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/datasource_default_values.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 { DatasourcePublicAPI } from '../types'; + +type VisState = { layers: Array<{ layerId: string }> } | { layerId: string }; + +interface MappedVisualValue { + truncateText: boolean; +} + +function hasSingleLayer(state: VisState): state is Extract { + return 'layerId' in state; +} + +function mergeValues(memo: MappedVisualValue, values: Partial, i: number) { + // first the first entry, overwrite + if (i === 0) { + return { ...memo, ...values }; + } + // after the first give priority to existent value + return { ...values, ...memo }; +} + +export function getDefaultVisualValuesForLayer( + state: VisState | undefined, + datasourceLayers: Record +): MappedVisualValue { + const defaultValues = { truncateText: true }; + if (!state) { + return defaultValues; + } + if (hasSingleLayer(state)) { + return Object.values( + datasourceLayers[state.layerId]?.getVisualDefaults() || {} + ).reduce(mergeValues, defaultValues); + } + return state.layers + .flatMap(({ layerId }) => Object.values(datasourceLayers[layerId]?.getVisualDefaults() || {})) + .reduce(mergeValues, defaultValues); +} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 8c5331100e903..eb82bb67c0829 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -303,6 +303,10 @@ export interface DatasourcePublicAPI { datasourceId: string; getTableSpec: () => Array<{ columnId: string }>; getOperationForColumnId: (columnId: string) => Operation | null; + /** + * Collect all default visual values given the current state + */ + getVisualDefaults: () => Record>; } export interface DatasourceDataPanelProps { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 65425b04129d3..027165a2eb5d0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -636,6 +636,117 @@ describe('xy_expression', () => { `); }); + describe('axis time', () => { + const defaultTimeLayer: LayerArgs = { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'time', + yScaleType: 'linear', + isHistogram: true, + palette: mockPaletteOutput, + }; + test('it should disable the new time axis for a line time layer when isHistogram is set to false', () => { + const { data } = sampleArgs(); + + const instance = shallow( + + ); + + const axisStyle = instance.find(Axis).first().prop('timeAxisLayerCount'); + + expect(axisStyle).toBe(0); + }); + test('it should enable the new time axis for a line time layer when isHistogram is set to true', () => { + const { data } = sampleArgs(); + const timeLayerArgs = createArgsWithLayers([defaultTimeLayer]); + + const instance = shallow( + + ); + + const axisStyle = instance.find(Axis).first().prop('timeAxisLayerCount'); + + expect(axisStyle).toBe(3); + }); + test('it should disable the new time axis for a vertical bar with break down dimension', () => { + const { data } = sampleArgs(); + const timeLayer: LayerArgs = { + ...defaultTimeLayer, + seriesType: 'bar', + }; + const timeLayerArgs = createArgsWithLayers([timeLayer]); + + const instance = shallow( + + ); + + const axisStyle = instance.find(Axis).first().prop('timeAxisLayerCount'); + + expect(axisStyle).toBe(0); + }); + + test('it should enable the new time axis for a stacked vertical bar with break down dimension', () => { + const { data } = sampleArgs(); + const timeLayer: LayerArgs = { + ...defaultTimeLayer, + seriesType: 'bar_stacked', + }; + const timeLayerArgs = createArgsWithLayers([timeLayer]); + + const instance = shallow( + + ); + + const axisStyle = instance.find(Axis).first().prop('timeAxisLayerCount'); + + expect(axisStyle).toBe(3); + }); + }); describe('endzones', () => { const { args } = sampleArgs(); const data: LensMultiTable = { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 01359c68c6da3..9c4c56281dae0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -497,10 +497,18 @@ export function XYChart({ if (xySeries.seriesKeys.length > 1) { const pointValue = xySeries.seriesKeys[0]; + const splitColumn = table.columns.find(({ id }) => id === layer.splitAccessor); + const splitFormatter = formatFactory(splitColumn && splitColumn.meta?.params); + points.push({ - row: table.rows.findIndex( - (row) => layer.splitAccessor && row[layer.splitAccessor] === pointValue - ), + row: table.rows.findIndex((row) => { + if (layer.splitAccessor) { + if (layersAlreadyFormatted[layer.splitAccessor]) { + return splitFormatter.convert(row[layer.splitAccessor]) === pointValue; + } + return row[layer.splitAccessor] === pointValue; + } + }), column: table.columns.findIndex((col) => col.id === layer.splitAccessor), value: pointValue, }); @@ -554,9 +562,8 @@ export function XYChart({ } as LegendPositionConfig; const isHistogramModeEnabled = filteredLayers.some( - ({ isHistogram, seriesType, splitAccessor }) => + ({ isHistogram, seriesType }) => isHistogram && - (seriesType.includes('stacked') || !splitAccessor) && (seriesType.includes('stacked') || !seriesType.includes('bar') || !chartHasMoreThanOneBarSeries) diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 1cd0bab48cd68..22d680caeb12c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -15,6 +15,7 @@ import type { ValidLayer, XYLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; import { hasIcon } from './xy_config_panel/reference_line_panel'; import { defaultReferenceLineColor } from './color_assignment'; +import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { const originalOrder = datasource @@ -173,7 +174,10 @@ export const buildExpression = ( ? [Math.min(5, state.legend.floatingColumns)] : [], maxLines: state.legend.maxLines ? [state.legend.maxLines] : [], - shouldTruncate: [state.legend.shouldTruncate ?? true], + shouldTruncate: [ + state.legend.shouldTruncate ?? + getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, + ], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 8330acf28264c..8957a522303e0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -706,8 +706,7 @@ export const getXyVisualization = ({ {label}, }} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index 6a43be64ec1d4..3a757c539f08e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -40,6 +40,7 @@ import { getScaleType } from '../to_expression'; import { ColorPicker } from './color_picker'; import { ReferenceLinePanel } from './reference_line_panel'; import { PalettePicker, TooltipWrapper } from '../../shared_components'; +import { getDefaultVisualValuesForLayer } from '../../shared_components/datasource_default_values'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -351,6 +352,12 @@ export const XyToolbar = memo(function XyToolbar( } ); + // Ask the datasource if it has a say about default truncation value + const defaultParamsFromDatasources = getDefaultVisualValuesForLayer( + state, + props.frame.datasourceLayers + ).truncateText; + return ( @@ -421,9 +428,9 @@ export const XyToolbar = memo(function XyToolbar( legend: { ...state.legend, maxLines: val }, }); }} - shouldTruncate={state?.legend.shouldTruncate ?? true} + shouldTruncate={state?.legend.shouldTruncate ?? defaultParamsFromDatasources} onTruncateLegendChange={() => { - const current = state?.legend.shouldTruncate ?? true; + const current = state?.legend.shouldTruncate ?? defaultParamsFromDatasources; setState({ ...state, legend: { ...state.legend, shouldTruncate: !current }, diff --git a/x-pack/plugins/license_management/public/application/app_context.tsx b/x-pack/plugins/license_management/public/application/app_context.tsx index 1f3581218ea71..4500965b15c37 100644 --- a/x-pack/plugins/license_management/public/application/app_context.tsx +++ b/x-pack/plugins/license_management/public/application/app_context.tsx @@ -6,9 +6,9 @@ */ import React, { createContext, useContext } from 'react'; -import { ScopedHistory } from 'kibana/public'; +import { Observable } from 'rxjs'; -import { CoreStart } from '../../../../../src/core/public'; +import { CoreStart, ScopedHistory, CoreTheme } from '../../../../../src/core/public'; import { LicensingPluginSetup, ILicense } from '../../../licensing/public'; import { TelemetryPluginStart } from '../../../../../src/plugins/telemetry/public'; import { ClientConfigType } from '../types'; @@ -33,6 +33,7 @@ export interface AppDependencies { initialLicense: ILicense; }; config: ClientConfigType; + theme$: Observable; } export const AppContextProvider = ({ diff --git a/x-pack/plugins/license_management/public/application/app_providers.tsx b/x-pack/plugins/license_management/public/application/app_providers.tsx index f6d40ce131853..704dd962c4f29 100644 --- a/x-pack/plugins/license_management/public/application/app_providers.tsx +++ b/x-pack/plugins/license_management/public/application/app_providers.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { Provider } from 'react-redux'; +import { KibanaThemeProvider } from '../shared_imports'; import { AppContextProvider, AppDependencies } from './app_context'; // @ts-ignore import { licenseManagementStore } from './store'; @@ -23,6 +24,7 @@ export const AppProviders = ({ appDependencies, children }: Props) => { plugins, services, store: { initialLicense }, + theme$, } = appDependencies; const { @@ -46,9 +48,11 @@ export const AppProviders = ({ appDependencies, children }: Props) => { return ( - - {children} - + + + {children} + + ); }; diff --git a/x-pack/plugins/license_management/public/plugin.ts b/x-pack/plugins/license_management/public/plugin.ts index 22fd5d756f160..67d954240d3c3 100644 --- a/x-pack/plugins/license_management/public/plugin.ts +++ b/x-pack/plugins/license_management/public/plugin.ts @@ -57,7 +57,7 @@ export class LicenseManagementUIPlugin id: PLUGIN.id, title: PLUGIN.title, order: 0, - mount: async ({ element, setBreadcrumbs, history }) => { + mount: async ({ element, setBreadcrumbs, history, theme$ }) => { const [coreStart, { telemetry }] = await getStartServices(); const initialLicense = await plugins.licensing.license$.pipe(first()).toPromise(); @@ -90,6 +90,7 @@ export class LicenseManagementUIPlugin initialLicense, }, docLinks: appDocLinks, + theme$, }; const { renderApp } = await import('./application'); diff --git a/x-pack/plugins/license_management/public/shared_imports.ts b/x-pack/plugins/license_management/public/shared_imports.ts index 695432684a660..878655c82c557 100644 --- a/x-pack/plugins/license_management/public/shared_imports.ts +++ b/x-pack/plugins/license_management/public/shared_imports.ts @@ -6,3 +6,5 @@ */ export { SectionLoading } from '../../../../src/plugins/es_ui_shared/public/'; + +export { KibanaThemeProvider } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index a05b06b086fff..8547bf41c4dee 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -58,6 +58,7 @@ export const MATCH = 'match'; export const MATCH_ANY = 'match_any'; export const WILDCARD = 'wildcard'; export const MAX_IMPORT_PAYLOAD_BYTES = 9000000; +export const MAX_IMPORT_SIZE = 10000; export const IMPORT_BUFFER_SIZE = 1000; export const LIST = 'list'; export const EXISTS = 'exists'; diff --git a/x-pack/plugins/lists/common/schemas/request/import_exceptions_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/import_exceptions_schema.mock.ts new file mode 100644 index 0000000000000..a9440520ec27b --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/import_exceptions_schema.mock.ts @@ -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 { + ImportExceptionListItemSchema, + ImportExceptionListItemSchemaDecoded, + ImportExceptionListSchemaDecoded, + ImportExceptionsListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; + +import { ENTRIES } from '../../constants.mock'; + +export const getImportExceptionsListSchemaMock = ( + listId = 'detection_list_id' +): ImportExceptionsListSchema => ({ + description: 'some description', + list_id: listId, + name: 'Query with a rule id', + type: 'detection', +}); + +export const getImportExceptionsListItemSchemaMock = ( + itemId = 'item_id_1', + listId = 'detection_list_id' +): ImportExceptionListItemSchema => ({ + description: 'some description', + entries: ENTRIES, + item_id: itemId, + list_id: listId, + name: 'Query with a rule id', + type: 'simple', +}); + +export const getImportExceptionsListSchemaDecodedMock = ( + listId = 'detection_list_id' +): ImportExceptionListSchemaDecoded => ({ + ...getImportExceptionsListSchemaMock(listId), + immutable: false, + meta: undefined, + namespace_type: 'single', + os_types: [], + tags: [], + version: 1, +}); + +export const getImportExceptionsListItemSchemaDecodedMock = ( + itemId = 'item_id_1', + listId = 'detection_list_id' +): ImportExceptionListItemSchemaDecoded => ({ + ...getImportExceptionsListItemSchemaMock(itemId, listId), + comments: [], + meta: undefined, + namespace_type: 'single', + os_types: [], + tags: [], +}); + +/** + * Given an array of exception lists and items, builds a stream + * @param items Array of exception lists and items objects with which to generate JSON + */ +export const toNdJsonString = (items: unknown[]): string => { + const stringOfExceptions = items.map((item) => JSON.stringify(item)); + + return stringOfExceptions.join('\n'); +}; diff --git a/x-pack/plugins/lists/common/schemas/response/found_exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/found_exception_list_item_schema.mock.ts index d06ab90e84168..18143d765cae9 100644 --- a/x-pack/plugins/lists/common/schemas/response/found_exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/found_exception_list_item_schema.mock.ts @@ -9,9 +9,11 @@ import type { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-l import { getExceptionListItemSchemaMock } from './exception_list_item_schema.mock'; -export const getFoundExceptionListItemSchemaMock = (): FoundExceptionListItemSchema => ({ - data: [getExceptionListItemSchemaMock()], +export const getFoundExceptionListItemSchemaMock = ( + count: number = 1 +): FoundExceptionListItemSchema => ({ + data: Array.from({ length: count }, getExceptionListItemSchemaMock), page: 1, per_page: 1, - total: 1, + total: count, }); diff --git a/x-pack/plugins/lists/server/config.mock.ts b/x-pack/plugins/lists/server/config.mock.ts index 98d59ef1c2a4d..a72eedf42eee9 100644 --- a/x-pack/plugins/lists/server/config.mock.ts +++ b/x-pack/plugins/lists/server/config.mock.ts @@ -11,6 +11,7 @@ import { LIST_INDEX, LIST_ITEM_INDEX, MAX_IMPORT_PAYLOAD_BYTES, + MAX_IMPORT_SIZE, } from '../common/constants.mock'; import { ConfigType } from './config'; @@ -25,5 +26,6 @@ export const getConfigMockDecoded = (): ConfigType => ({ importTimeout: IMPORT_TIMEOUT, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, + maxExceptionsImportSize: MAX_IMPORT_SIZE, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES, }); diff --git a/x-pack/plugins/lists/server/config.test.ts b/x-pack/plugins/lists/server/config.test.ts index 2b1e26f85a44d..bebc58e76723a 100644 --- a/x-pack/plugins/lists/server/config.test.ts +++ b/x-pack/plugins/lists/server/config.test.ts @@ -84,4 +84,14 @@ describe('config_schema', () => { '[importTimeout]: duration cannot be greater than 30 minutes' ); }); + + test('it throws if the "maxExceptionsImportSize" value is less than 0', () => { + const mock: ConfigType = { + ...getConfigMockDecoded(), + maxExceptionsImportSize: -1, + }; + expect(() => ConfigSchema.validate(mock)).toThrow( + '[maxExceptionsImportSize]: Value must be equal to or greater than [1].' + ); + }); }); diff --git a/x-pack/plugins/lists/server/config.ts b/x-pack/plugins/lists/server/config.ts index 0bb070da05137..4322c6d296a43 100644 --- a/x-pack/plugins/lists/server/config.ts +++ b/x-pack/plugins/lists/server/config.ts @@ -21,6 +21,7 @@ export const ConfigSchema = schema.object({ }), listIndex: schema.string({ defaultValue: '.lists' }), listItemIndex: schema.string({ defaultValue: '.items' }), + maxExceptionsImportSize: schema.number({ defaultValue: 10000, min: 1 }), maxImportPayloadBytes: schema.number({ defaultValue: 9000000, min: 1 }), }); diff --git a/x-pack/plugins/lists/server/routes/export_exception_list_route.ts b/x-pack/plugins/lists/server/routes/export_exception_list_route.ts index b91537b6cb3b1..f13e84c6e58dd 100644 --- a/x-pack/plugins/lists/server/routes/export_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/export_exception_list_route.ts @@ -13,7 +13,7 @@ import type { ListsPluginRouter } from '../types'; import { buildRouteValidation, buildSiemResponse, getExceptionListClient } from './utils'; -export const exportExceptionListRoute = (router: ListsPluginRouter): void => { +export const exportExceptionsRoute = (router: ListsPluginRouter): void => { router.post( { options: { diff --git a/x-pack/plugins/lists/server/routes/import_exceptions_route.ts b/x-pack/plugins/lists/server/routes/import_exceptions_route.ts new file mode 100644 index 0000000000000..9db8c27c5397d --- /dev/null +++ b/x-pack/plugins/lists/server/routes/import_exceptions_route.ts @@ -0,0 +1,81 @@ +/* + * Copyright 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 { extname } from 'path'; + +import { schema } from '@kbn/config-schema'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { importExceptionsResponseSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; +import { validate } from '@kbn/securitysolution-io-ts-utils'; +import { ImportQuerySchemaDecoded, importQuerySchema } from '@kbn/securitysolution-io-ts-types'; + +import type { ListsPluginRouter } from '../types'; +import { ConfigType } from '../config'; + +import { buildRouteValidation, buildSiemResponse, getExceptionListClient } from './utils'; + +/** + * Takes an ndjson file of exception lists and exception list items and + * imports them by either creating or updating lists/items given a clients + * choice to overwrite any matching lists + */ +export const importExceptionsRoute = (router: ListsPluginRouter, config: ConfigType): void => { + router.post( + { + options: { + body: { + maxBytes: config.maxImportPayloadBytes, + output: 'stream', + }, + tags: ['access:lists-all'], + }, + path: `${EXCEPTION_LIST_URL}/_import`, + validate: { + body: schema.any(), // validation on file object is accomplished later in the handler. + query: buildRouteValidation( + importQuerySchema + ), + }, + }, + async (context, request, response) => { + const exceptionListsClient = getExceptionListClient(context); + const siemResponse = buildSiemResponse(response); + + try { + const { filename } = request.body.file.hapi; + const fileExtension = extname(filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return siemResponse.error({ + body: `Invalid file extension ${fileExtension}`, + statusCode: 400, + }); + } + + const importsSummary = await exceptionListsClient.importExceptionListAndItems({ + exceptionsToImport: request.body.file, + maxExceptionsImportSize: config.maxExceptionsImportSize, + overwrite: request.query.overwrite, + }); + + const [validated, errors] = validate(importsSummary, importExceptionsResponseSchema); + + if (errors != null) { + return siemResponse.error({ body: errors, statusCode: 500 }); + } else { + return response.ok({ body: validated ?? {} }); + } + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/lists/server/routes/index.ts b/x-pack/plugins/lists/server/routes/index.ts index f8d4deea344b2..a0fc404a4266f 100644 --- a/x-pack/plugins/lists/server/routes/index.ts +++ b/x-pack/plugins/lists/server/routes/index.ts @@ -25,6 +25,7 @@ export * from './find_exception_list_item_route'; export * from './find_exception_list_route'; export * from './find_list_item_route'; export * from './find_list_route'; +export * from './import_exceptions_route'; export * from './import_list_item_route'; export * from './init_routes'; export * from './patch_list_item_route'; diff --git a/x-pack/plugins/lists/server/routes/init_routes.ts b/x-pack/plugins/lists/server/routes/init_routes.ts index 2511596ca8463..b8132d08809ba 100644 --- a/x-pack/plugins/lists/server/routes/init_routes.ts +++ b/x-pack/plugins/lists/server/routes/init_routes.ts @@ -22,13 +22,14 @@ import { deleteListIndexRoute, deleteListItemRoute, deleteListRoute, - exportExceptionListRoute, + exportExceptionsRoute, exportListItemRoute, findEndpointListItemRoute, findExceptionListItemRoute, findExceptionListRoute, findListItemRoute, findListRoute, + importExceptionsRoute, importListItemRoute, patchListItemRoute, patchListRoute, @@ -72,13 +73,16 @@ export const initRoutes = (router: ListsPluginRouter, config: ConfigType): void readListIndexRoute(router); deleteListIndexRoute(router); + // exceptions import/export + exportExceptionsRoute(router); + importExceptionsRoute(router, config); + // exception lists createExceptionListRoute(router); readExceptionListRoute(router); updateExceptionListRoute(router); deleteExceptionListRoute(router); findExceptionListRoute(router); - exportExceptionListRoute(router); // exception list items createExceptionListItemRoute(router); diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/files/import.ndjson b/x-pack/plugins/lists/server/scripts/exception_lists/files/import.ndjson new file mode 100644 index 0000000000000..123a683630e36 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/exception_lists/files/import.ndjson @@ -0,0 +1,3 @@ +{"_version":"WzEyOTcxLDFd","created_at":"2021-10-19T22:16:22.426Z","created_by":"elastic","description":"Query with a rule_id that acts like an external id","id":"3120bfa0-312a-11ec-9af9-ebd1fe0a2379","immutable":false,"list_id":"7d7cccb8-db72-4667-b1f3-648efad7c1ee","name":"Query with a rule id Number 1","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"e4daafa2-a60b-4e97-8eb4-2ed54356308f","type":"detection","updated_at":"2021-10-19T22:16:22.491Z","updated_by":"elastic","version":1} +{"_version":"WzEyOTc1LDFd","comments":[],"created_at":"2021-10-19T22:16:36.567Z","created_by":"elastic","description":"Query with a rule id Number 1 - exception list item","entries":[{"field":"@timestamp","operator":"included","type":"exists"}],"id":"398ea580-312a-11ec-9af9-ebd1fe0a2379","item_id":"f7fd00bb-dba8-4c93-9d59-6cbd427b6330","list_id":"7d7cccb8-db72-4667-b1f3-648efad7c1ee","name":"Query with a rule id Number 1 - exception list item","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"54fecdba-1b36-467a-867c-a49aaaa84dcc","type":"simple","updated_at":"2021-10-19T22:16:36.634Z","updated_by":"elastic"} +{"exported_exception_list_count":1,"exported_exception_list_item_count":1,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0} diff --git a/x-pack/plugins/lists/server/scripts/import_exception_lists.sh b/x-pack/plugins/lists/server/scripts/import_exception_lists.sh new file mode 100755 index 0000000000000..51a447e735136 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/import_exception_lists.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# +# Copyright 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. +# + +set -e +./check_env_variables.sh + +# Uses a defaults if no argument is specified +FILE=${1:-./exception_lists/files/import.ndjson} + +# ./import_list_items.sh ip_list ./exception_lists/files/import.ndjson +curl -s -k \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/exception_lists/_import" \ + -H 'kbn-xsrf: true' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + --form file=@${FILE} \ + | jq .; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 542598fc82c90..08586c37e1eae 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -12,6 +12,7 @@ import type { ExceptionListSummarySchema, FoundExceptionListItemSchema, FoundExceptionListSchema, + ImportExceptionsResponseSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; @@ -34,6 +35,8 @@ import { GetExceptionListItemOptions, GetExceptionListOptions, GetExceptionListSummaryOptions, + ImportExceptionListAndItemsAsArrayOptions, + ImportExceptionListAndItemsOptions, UpdateEndpointListItemOptions, UpdateExceptionListItemOptions, UpdateExceptionListOptions, @@ -59,6 +62,10 @@ import { } from './find_exception_list_items'; import { createEndpointList } from './create_endpoint_list'; import { createEndpointTrustedAppsList } from './create_endpoint_trusted_apps_list'; +import { + importExceptionsAsArray, + importExceptionsAsStream, +} from './import_exception_list_and_items'; export class ExceptionListClient { private readonly user: string; @@ -70,6 +77,13 @@ export class ExceptionListClient { this.savedObjectsClient = savedObjectsClient; } + /** + * Fetch an exception list parent container + * @params listId {string | undefined} the "list_id" of an exception list + * @params id {string | undefined} the "id" of an exception list + * @params namespaceType {string | undefined} saved object namespace (single | agnostic) + * @return {ExceptionListSchema | null} the found exception list or null if none exists + */ public getExceptionList = async ({ listId, id, @@ -79,6 +93,13 @@ export class ExceptionListClient { return getExceptionList({ id, listId, namespaceType, savedObjectsClient }); }; + /** + * Fetch an exception list parent container + * @params listId {string | undefined} the "list_id" of an exception list + * @params id {string | undefined} the "id" of an exception list + * @params namespaceType {string | undefined} saved object namespace (single | agnostic) + * @return {ExceptionListSummarySchema | null} summary of exception list item os types + */ public getExceptionListSummary = async ({ listId, id, @@ -88,6 +109,13 @@ export class ExceptionListClient { return getExceptionListSummary({ id, listId, namespaceType, savedObjectsClient }); }; + /** + * Fetch an exception list item container + * @params listId {string | undefined} the "list_id" of an exception list + * @params id {string | undefined} the "id" of an exception list + * @params namespaceType {string | undefined} saved object namespace (single | agnostic) + * @return {ExceptionListSummarySchema | null} the found exception list item or null if none exists + */ public getExceptionListItem = async ({ itemId, id, @@ -209,6 +237,19 @@ export class ExceptionListClient { return getExceptionListItem({ id, itemId, namespaceType: 'agnostic', savedObjectsClient }); }; + /** + * Create an exception list container + * @params description {string} a description of the exception list + * @params immutable {boolean} a description of the exception list + * @params listId {string} the "list_id" of the exception list + * @params meta {object | undefined} + * @params name {string} the "name" of the exception list + * @params namespaceType {string} saved object namespace (single | agnostic) + * @params tags {array} user assigned tags of exception list + * @params type {string} container type + * @params version {number} document version + * @return {ExceptionListSchema} the created exception list parent container + */ public createExceptionList = async ({ description, immutable, @@ -236,6 +277,20 @@ export class ExceptionListClient { }); }; + /** + * Update an existing exception list container + * @params _version {string | undefined} document version + * @params id {string | undefined} the "id" of the exception list + * @params description {string | undefined} a description of the exception list + * @params listId {string | undefined} the "list_id" of the exception list + * @params meta {object | undefined} + * @params name {string | undefined} the "name" of the exception list + * @params namespaceType {string} saved object namespace (single | agnostic) + * @params tags {array | undefined} user assigned tags of exception list + * @params type {string | undefined} container type + * @params version {number | undefined} document version + * @return {ExceptionListSchema | null} the updated exception list parent container + */ public updateExceptionList = async ({ _version, id, @@ -244,7 +299,6 @@ export class ExceptionListClient { meta, name, namespaceType, - osTypes, tags, type, version, @@ -258,7 +312,6 @@ export class ExceptionListClient { meta, name, namespaceType, - osTypes, savedObjectsClient, tags, type, @@ -267,6 +320,13 @@ export class ExceptionListClient { }); }; + /** + * Delete an exception list container by either id or list_id + * @params listId {string | undefined} the "list_id" of an exception list + * @params id {string | undefined} the "id" of an exception list + * @params namespaceType {string} saved object namespace (single | agnostic) + * @return {ExceptionListSchema | null} the deleted exception list or null if none exists + */ public deleteExceptionList = async ({ id, listId, @@ -281,6 +341,20 @@ export class ExceptionListClient { }); }; + /** + * Create an exception list item container + * @params description {string} a description of the exception list + * @params entries {array} an array with the exception list item entries + * @params itemId {string} the "item_id" of the exception list item + * @params listId {string} the "list_id" of the parent exception list + * @params meta {object | undefined} + * @params name {string} the "name" of the exception list + * @params namespaceType {string} saved object namespace (single | agnostic) + * @params osTypes {array} item os types to apply + * @params tags {array} user assigned tags of exception list + * @params type {string} container type + * @return {ExceptionListItemSchema} the created exception list item container + */ public createExceptionListItem = async ({ comments, description, @@ -312,6 +386,22 @@ export class ExceptionListClient { }); }; + /** + * Update an existing exception list item + * @params _version {string | undefined} document version + * @params comments {array} user comments attached to item + * @params entries {array} item exception entries logic + * @params id {string | undefined} the "id" of the exception list item + * @params description {string | undefined} a description of the exception list + * @params itemId {string | undefined} the "item_id" of the exception list item + * @params meta {object | undefined} + * @params name {string | undefined} the "name" of the exception list + * @params namespaceType {string} saved object namespace (single | agnostic) + * @params osTypes {array} item os types to apply + * @params tags {array | undefined} user assigned tags of exception list + * @params type {string | undefined} container type + * @return {ExceptionListItemSchema | null} the updated exception list item or null if none exists + */ public updateExceptionListItem = async ({ _version, comments, @@ -345,6 +435,13 @@ export class ExceptionListClient { }); }; + /** + * Delete an exception list item by either id or item_id + * @params itemId {string | undefined} the "item_id" of an exception list item + * @params id {string | undefined} the "id" of an exception list item + * @params namespaceType {string} saved object namespace (single | agnostic) + * @return {ExceptionListItemSchema | null} the deleted exception list item or null if none exists + */ public deleteExceptionListItem = async ({ id, itemId, @@ -359,6 +456,12 @@ export class ExceptionListClient { }); }; + /** + * Delete an exception list item by id + * @params id {string | undefined} the "id" of an exception list item + * @params namespaceType {string} saved object namespace (single | agnostic) + * @return {void} + */ public deleteExceptionListItemById = async ({ id, namespaceType, @@ -498,6 +601,13 @@ export class ExceptionListClient { }); }; + /** + * Export an exception list parent container and it's items + * @params listId {string | undefined} the "list_id" of an exception list + * @params id {string | undefined} the "id" of an exception list + * @params namespaceType {string | undefined} saved object namespace (single | agnostic) + * @return {ExportExceptionListAndItemsReturn | null} the ndjson of the list and items to export or null if none exists + */ public exportExceptionListAndItems = async ({ listId, id, @@ -512,4 +622,50 @@ export class ExceptionListClient { savedObjectsClient, }); }; + + /** + * Import exception lists parent containers and items as stream + * @params exceptionsToImport {stream} ndjson stream of lists and items + * @params maxExceptionsImportSize {number} the max number of lists and items to import, defaults to 10,000 + * @params overwrite {boolean} whether or not to overwrite an exception list with imported list if a matching list_id found + * @return {ImportExceptionsResponseSchema} summary of imported count and errors + */ + public importExceptionListAndItems = async ({ + exceptionsToImport, + maxExceptionsImportSize, + overwrite, + }: ImportExceptionListAndItemsOptions): Promise => { + const { savedObjectsClient, user } = this; + + return importExceptionsAsStream({ + exceptionsToImport, + maxExceptionsImportSize, + overwrite, + savedObjectsClient, + user, + }); + }; + + /** + * Import exception lists parent containers and items as array + * @params exceptionsToImport {stream} array of lists and items + * @params maxExceptionsImportSize {number} the max number of lists and items to import, defaults to 10,000 + * @params overwrite {boolean} whether or not to overwrite an exception list with imported list if a matching list_id found + * @return {ImportExceptionsResponseSchema} summary of imported count and errors + */ + public importExceptionListAndItemsAsArray = async ({ + exceptionsToImport, + maxExceptionsImportSize, + overwrite, + }: ImportExceptionListAndItemsAsArrayOptions): Promise => { + const { savedObjectsClient, user } = this; + + return importExceptionsAsArray({ + exceptionsToImport, + maxExceptionsImportSize, + overwrite, + savedObjectsClient, + user, + }); + }; } diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index d6edf83428587..4035cbcf7a3fb 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { Readable } from 'stream'; + import { SavedObjectsClientContract } from 'kibana/server'; import type { CreateCommentsArray, @@ -20,6 +22,8 @@ import type { Id, IdOrUndefined, Immutable, + ImportExceptionListItemSchema, + ImportExceptionsListSchema, ItemId, ItemIdOrUndefined, ListId, @@ -232,3 +236,15 @@ export interface ExportExceptionListAndItemsReturn { exportData: string; exportDetails: ExportExceptionDetails; } + +export interface ImportExceptionListAndItemsOptions { + exceptionsToImport: Readable; + maxExceptionsImportSize: number; + overwrite: boolean; +} + +export interface ImportExceptionListAndItemsAsArrayOptions { + exceptionsToImport: Array; + maxExceptionsImportSize: number; + overwrite: boolean; +} diff --git a/x-pack/plugins/lists/server/services/exception_lists/import_exception_list_and_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/import_exception_list_and_items.test.ts new file mode 100644 index 0000000000000..31b6c7bf9750b --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/import_exception_list_and_items.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright 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 { Readable } from 'stream'; + +import { SavedObjectsClientContract } from 'kibana/server'; + +import { + getImportExceptionsListItemSchemaMock, + getImportExceptionsListSchemaMock, +} from '../../../../lists/common/schemas/request/import_exceptions_schema.mock'; + +import { importExceptionsAsStream } from './import_exception_list_and_items'; +import { importExceptionLists } from './utils/import/import_exception_lists'; +import { importExceptionListItems } from './utils/import/import_exception_list_items'; + +jest.mock('./utils/import/import_exception_lists'); +jest.mock('./utils/import/import_exception_list_items'); + +const toReadable = (items: unknown[]): Readable => { + const stringOfExceptions = items.map((item) => JSON.stringify(item)); + + return new Readable({ + read(): void { + this.push(stringOfExceptions.join('\n')); + this.push(null); + }, + }); +}; + +describe('import_exception_list_and_items', () => { + beforeEach(() => { + (importExceptionLists as jest.Mock).mockResolvedValue({ + errors: [], + success: true, + success_count: 1, + }); + (importExceptionListItems as jest.Mock).mockResolvedValue({ + errors: [], + success: true, + success_count: 1, + }); + }); + + test('it should report success false if an error occurred importing lists', async () => { + (importExceptionLists as jest.Mock).mockResolvedValue({ + errors: [{ error: { message: 'some error occurred', status_code: 400 } }], + success: false, + success_count: 1, + }); + + const result = await importExceptionsAsStream({ + exceptionsToImport: toReadable([ + getImportExceptionsListSchemaMock('test_list_id'), + getImportExceptionsListItemSchemaMock('test_item_id', 'test_list_id'), + ]), + maxExceptionsImportSize: 10000, + overwrite: false, + savedObjectsClient: {} as SavedObjectsClientContract, + user: 'elastic', + }); + expect(result).toEqual({ + errors: [{ error: { message: 'some error occurred', status_code: 400 } }], + success: false, + success_count: 2, + success_count_exception_list_items: 1, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_exception_lists: false, + }); + }); + + test('it should report success false if an error occurred importing items', async () => { + (importExceptionListItems as jest.Mock).mockResolvedValue({ + errors: [{ error: { message: 'some error occurred', status_code: 400 } }], + success: false, + success_count: 1, + }); + + const result = await importExceptionsAsStream({ + exceptionsToImport: toReadable([ + getImportExceptionsListSchemaMock('test_list_id'), + getImportExceptionsListItemSchemaMock('test_item_id', 'test_list_id'), + ]), + maxExceptionsImportSize: 10000, + overwrite: false, + savedObjectsClient: {} as SavedObjectsClientContract, + user: 'elastic', + }); + expect(result).toEqual({ + errors: [{ error: { message: 'some error occurred', status_code: 400 } }], + success: false, + success_count: 2, + success_count_exception_list_items: 1, + success_count_exception_lists: 1, + success_exception_list_items: false, + success_exception_lists: true, + }); + }); + + test('it should report success true if no errors occurred importing lists and items', async () => { + const result = await importExceptionsAsStream({ + exceptionsToImport: toReadable([ + getImportExceptionsListSchemaMock('test_list_id'), + getImportExceptionsListItemSchemaMock('test_item_id', 'test_list_id'), + ]), + maxExceptionsImportSize: 10000, + overwrite: false, + savedObjectsClient: {} as SavedObjectsClientContract, + user: 'elastic', + }); + expect(result).toEqual({ + errors: [], + success: true, + success_count: 2, + success_count_exception_list_items: 1, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_exception_lists: true, + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/import_exception_list_and_items.ts b/x-pack/plugins/lists/server/services/exception_lists/import_exception_list_and_items.ts new file mode 100644 index 0000000000000..a982ef1a85b34 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/import_exception_list_and_items.ts @@ -0,0 +1,193 @@ +/* + * Copyright 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 { Readable } from 'stream'; + +import { + BulkErrorSchema, + ImportExceptionListItemSchema, + ImportExceptionListItemSchemaDecoded, + ImportExceptionListSchemaDecoded, + ImportExceptionsListSchema, + ImportExceptionsResponseSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { createPromiseFromStreams } from '@kbn/utils'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { chunk } from 'lodash/fp'; + +import { importExceptionLists } from './utils/import/import_exception_lists'; +import { importExceptionListItems } from './utils/import/import_exception_list_items'; +import { getTupleErrorsAndUniqueExceptionLists } from './utils/import/dedupe_incoming_lists'; +import { getTupleErrorsAndUniqueExceptionListItems } from './utils/import/dedupe_incoming_items'; +import { + createExceptionsStreamFromNdjson, + exceptionsChecksFromArray, +} from './utils/import/create_exceptions_stream_logic'; + +export interface PromiseFromStreams { + lists: Array; + items: Array; +} +export interface ImportExceptionsOk { + id?: string; + item_id?: string; + list_id?: string; + status_code: number; + message?: string; +} + +export type ImportResponse = ImportExceptionsOk | BulkErrorSchema; + +export type PromiseStream = ImportExceptionsListSchema | ImportExceptionListItemSchema | Error; + +export interface ImportDataResponse { + success: boolean; + success_count: number; + errors: BulkErrorSchema[]; +} +interface ImportExceptionListAndItemsOptions { + exceptions: PromiseFromStreams; + overwrite: boolean; + savedObjectsClient: SavedObjectsClientContract; + user: string; +} + +interface ImportExceptionListAndItemsAsStreamOptions { + exceptionsToImport: Readable; + maxExceptionsImportSize: number; + overwrite: boolean; + savedObjectsClient: SavedObjectsClientContract; + user: string; +} + +interface ImportExceptionListAndItemsAsArrayOptions { + exceptionsToImport: Array; + maxExceptionsImportSize: number; + overwrite: boolean; + savedObjectsClient: SavedObjectsClientContract; + user: string; +} + +export type ExceptionsImport = Array; + +export const CHUNK_PARSED_OBJECT_SIZE = 100; + +/** + * Import exception lists parent containers and items as stream. The shape of the list and items + * will be validated here as well. + * @params exceptionsToImport {stream} ndjson stream of lists and items to be imported + * @params maxExceptionsImportSize {number} the max number of lists and items to import, defaults to 10,000 + * @params overwrite {boolean} whether or not to overwrite an exception list with imported list if a matching list_id found + * @params savedObjectsClient {object} SO client + * @params user {string} user importing list and items + * @return {ImportExceptionsResponseSchema} summary of imported count and errors + */ +export const importExceptionsAsStream = async ({ + exceptionsToImport, + maxExceptionsImportSize, + overwrite, + savedObjectsClient, + user, +}: ImportExceptionListAndItemsAsStreamOptions): Promise => { + // validation of import and sorting of lists and items + const readStream = createExceptionsStreamFromNdjson(maxExceptionsImportSize); + const [parsedObjects] = await createPromiseFromStreams([ + exceptionsToImport, + ...readStream, + ]); + + return importExceptions({ + exceptions: parsedObjects, + overwrite, + savedObjectsClient, + user, + }); +}; + +/** + * Import exception lists parent containers and items as array. The shape of the list and items + * will be validated here as well. + * @params exceptionsToImport {array} lists and items to be imported + * @params maxExceptionsImportSize {number} the max number of lists and items to import, defaults to 10,000 + * @params overwrite {boolean} whether or not to overwrite an exception list with imported list if a matching list_id found + * @params savedObjectsClient {object} SO client + * @params user {string} user importing list and items + * @return {ImportExceptionsResponseSchema} summary of imported count and errors + */ +export const importExceptionsAsArray = async ({ + exceptionsToImport, + maxExceptionsImportSize, + overwrite, + savedObjectsClient, + user, +}: ImportExceptionListAndItemsAsArrayOptions): Promise => { + // validation of import and sorting of lists and items + const objectsToImport = exceptionsChecksFromArray(exceptionsToImport, maxExceptionsImportSize); + + return importExceptions({ + exceptions: objectsToImport, + overwrite, + savedObjectsClient, + user, + }); +}; + +const importExceptions = async ({ + exceptions, + overwrite, + savedObjectsClient, + user, +}: ImportExceptionListAndItemsOptions): Promise => { + // removal of duplicates + const [exceptionListDuplicateErrors, uniqueExceptionLists] = + getTupleErrorsAndUniqueExceptionLists(exceptions.lists); + const [exceptionListItemsDuplicateErrors, uniqueExceptionListItems] = + getTupleErrorsAndUniqueExceptionListItems(exceptions.items); + + // chunking of validated import stream + const chunkParsedListObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueExceptionLists); + const chunkParsedItemsObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueExceptionListItems); + + // where the magic happens - purposely importing parent exception + // containers first, items second + const importExceptionListsResponse = await importExceptionLists({ + isOverwrite: overwrite, + listsChunks: chunkParsedListObjects, + savedObjectsClient, + user, + }); + const importExceptionListItemsResponse = await importExceptionListItems({ + isOverwrite: overwrite, + itemsChunks: chunkParsedItemsObjects, + savedObjectsClient, + user, + }); + + const importsSummary = { + errors: [ + ...importExceptionListsResponse.errors, + ...exceptionListDuplicateErrors, + ...importExceptionListItemsResponse.errors, + ...exceptionListItemsDuplicateErrors, + ], + success_count_exception_list_items: importExceptionListItemsResponse.success_count, + success_count_exception_lists: importExceptionListsResponse.success_count, + success_exception_list_items: + importExceptionListItemsResponse.errors.length === 0 && + exceptionListItemsDuplicateErrors.length === 0, + success_exception_lists: + importExceptionListsResponse.errors.length === 0 && exceptionListDuplicateErrors.length === 0, + }; + + return { + ...importsSummary, + success: importsSummary.success_exception_list_items && importsSummary.success_exception_lists, + success_count: + importsSummary.success_count_exception_lists + + importsSummary.success_count_exception_list_items, + }; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts index 53e0f82a2ba76..7efe64fb96070 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts @@ -15,7 +15,6 @@ import type { MetaOrUndefined, NameOrUndefined, NamespaceType, - OsTypeArray, TagsOrUndefined, _VersionOrUndefined, } from '@kbn/securitysolution-io-ts-list-types'; @@ -34,7 +33,6 @@ interface UpdateExceptionListOptions { description: DescriptionOrUndefined; savedObjectsClient: SavedObjectsClientContract; namespaceType: NamespaceType; - osTypes: OsTypeArray; listId: ListIdOrUndefined; meta: MetaOrUndefined; user: string; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.test.ts new file mode 100644 index 0000000000000..4427b706cf268 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsBulkCreateObject, SavedObjectsClientContract } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects/exceptions_list_so_schema'; +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { ENTRIES } from '../../../../../common/constants.mock'; + +import { bulkCreateImportedItems } from './bulk_create_imported_items'; + +describe('bulkCreateImportedItems', () => { + const sampleItems: Array> = [ + { + attributes: { + comments: [], + created_at: new Date().toISOString(), + created_by: 'elastic', + description: 'description here', + entries: ENTRIES, + immutable: undefined, + item_id: 'item-id', + list_id: 'list-id', + list_type: 'item', + meta: undefined, + name: 'my exception item', + os_types: [], + tags: [], + tie_breaker_id: '123456', + type: 'detection', + updated_by: 'elastic', + version: undefined, + }, + type: 'exception-list', + }, + ]; + let savedObjectsClient: jest.Mocked; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('returns empty array if no items to create', async () => { + const response = await bulkCreateImportedItems({ + itemsToCreate: [], + savedObjectsClient, + }); + + expect(response).toEqual([]); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + }); + + it('returns formatted error responses', async () => { + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + attributes: {}, + error: { + error: 'Internal Server Error', + message: 'Unexpected bulk response [400]', + statusCode: 500, + }, + id: '0dc73480-5664-11ec-af96-8349972169c7', + references: [], + type: 'exception-list', + }, + ], + }); + + const response = await bulkCreateImportedItems({ + itemsToCreate: sampleItems, + savedObjectsClient, + }); + + expect(response).toEqual([ + { + error: { + message: 'Unexpected bulk response [400]', + status_code: 500, + }, + item_id: '(unknown item_id)', + }, + ]); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalled(); + }); + + it('returns formatted success responses', async () => { + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + attributes: { + description: 'some description', + name: 'Query with a rule id', + tags: [], + type: 'detection', + updated_by: 'elastic', + }, + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + namespaces: ['default'], + references: [], + type: 'exception-list', + updated_at: '2021-12-06T07:35:27.941Z', + version: 'WzE0MTc5MiwxXQ==', + }, + ], + }); + + const response = await bulkCreateImportedItems({ + itemsToCreate: sampleItems, + savedObjectsClient, + }); + + expect(response).toEqual([ + { + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + status_code: 200, + }, + ]); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.ts new file mode 100644 index 0000000000000..3cb45bf035170 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.ts @@ -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 { has } from 'lodash/fp'; +import { SavedObjectsBulkCreateObject, SavedObjectsClientContract } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects/exceptions_list_so_schema'; +import { ImportResponse } from '../../import_exception_list_and_items'; + +/** + * Helper to bulk create exception list items + * container + * @param itemsToCreate {array} - exception items to be bulk created + * @param savedObjectsClient {object} + * @returns {array} returns array of success and error formatted responses + */ +export const bulkCreateImportedItems = async ({ + itemsToCreate, + savedObjectsClient, +}: { + itemsToCreate: Array>; + savedObjectsClient: SavedObjectsClientContract; +}): Promise => { + if (!itemsToCreate.length) { + return []; + } + const bulkCreateResponse = await savedObjectsClient.bulkCreate(itemsToCreate, { + overwrite: false, + }); + + return bulkCreateResponse.saved_objects.map((so) => { + if (has('error', so) && so.error != null) { + return { + error: { + message: so.error.message, + status_code: so.error.statusCode ?? 400, + }, + item_id: '(unknown item_id)', + }; + } else { + return { + id: so.id, + status_code: 200, + }; + } + }); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.test.ts new file mode 100644 index 0000000000000..caf3651935610 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright 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 { SavedObjectsBulkCreateObject, SavedObjectsClientContract } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects/exceptions_list_so_schema'; +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; + +import { bulkCreateImportedLists } from './bulk_create_imported_lists'; + +describe('bulkCreateImportedLists', () => { + const sampleLists: Array> = [ + { + attributes: { + comments: undefined, + created_at: new Date().toISOString(), + created_by: 'elastic', + description: 'some description', + entries: undefined, + immutable: false, + item_id: undefined, + list_id: 'list-id', + list_type: 'list', + meta: undefined, + name: 'my list name', + os_types: [], + tags: [], + tie_breaker_id: '123456', + type: 'detection', + updated_by: 'elastic', + version: undefined, + }, + type: 'exception-list', + }, + ]; + let savedObjectsClient: jest.Mocked; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('returns empty array if no lists to create', async () => { + const response = await bulkCreateImportedLists({ + listsToCreate: [], + savedObjectsClient, + }); + + expect(response).toEqual([]); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + }); + + it('returns formatted error responses', async () => { + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + attributes: {}, + error: { + error: 'Internal Server Error', + message: 'Unexpected bulk response [400]', + statusCode: 500, + }, + id: '0dc73480-5664-11ec-af96-8349972169c7', + references: [], + type: 'exception-list', + }, + ], + }); + + const response = await bulkCreateImportedLists({ + listsToCreate: sampleLists, + savedObjectsClient, + }); + + expect(response).toEqual([ + { + error: { + message: 'Unexpected bulk response [400]', + status_code: 500, + }, + list_id: '(unknown list_id)', + }, + ]); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalled(); + }); + + it('returns formatted success responses', async () => { + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + attributes: { + description: 'some description', + name: 'Query with a rule id', + tags: [], + type: 'detection', + updated_by: 'elastic', + }, + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + namespaces: ['default'], + references: [], + type: 'exception-list', + updated_at: '2021-12-06T07:35:27.941Z', + version: 'WzE0MTc5MiwxXQ==', + }, + ], + }); + + const response = await bulkCreateImportedLists({ + listsToCreate: sampleLists, + savedObjectsClient, + }); + + expect(response).toEqual([ + { + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + status_code: 200, + }, + ]); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.ts new file mode 100644 index 0000000000000..5b18315b4cd43 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.ts @@ -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 { has } from 'lodash/fp'; +import { SavedObjectsBulkCreateObject, SavedObjectsClientContract } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects/exceptions_list_so_schema'; +import { ImportResponse } from '../../import_exception_list_and_items'; + +/** + * Helper to bulk create exception list parent + * containers + * @param listsToCreate {array} - exception lists to be bulk created + * @param savedObjectsClient {object} + * @returns {array} returns array of success and error formatted responses + */ +export const bulkCreateImportedLists = async ({ + listsToCreate, + savedObjectsClient, +}: { + listsToCreate: Array>; + savedObjectsClient: SavedObjectsClientContract; +}): Promise => { + if (!listsToCreate.length) { + return []; + } + const bulkCreateResponse = await savedObjectsClient.bulkCreate(listsToCreate, { + overwrite: false, + }); + + return bulkCreateResponse.saved_objects.map((so) => { + if (has('error', so) && so.error != null) { + return { + error: { + message: so.error.message, + status_code: so.error.statusCode ?? 400, + }, + list_id: '(unknown list_id)', + }; + } else { + return { + id: so.id, + status_code: 200, + }; + } + }); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_items.test.ts new file mode 100644 index 0000000000000..5f2fb3f11ef98 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_items.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsBulkUpdateObject, SavedObjectsClientContract } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects/exceptions_list_so_schema'; +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { ENTRIES } from '../../../../../common/constants.mock'; + +import { bulkUpdateImportedItems } from './bulk_update_imported_items'; + +describe('bulkUpdateImportedItems', () => { + const sampleItems: Array> = [ + { + attributes: { + comments: [], + description: 'updated item description', + entries: ENTRIES, + meta: undefined, + name: 'updated name', + os_types: [], + tags: [], + type: 'detection', + updated_by: 'elastic', + }, + id: '1234', + type: 'exception-list', + }, + ]; + let savedObjectsClient: jest.Mocked; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('returns empty array if no items to create', async () => { + const response = await bulkUpdateImportedItems({ + itemsToUpdate: [], + savedObjectsClient, + }); + + expect(response).toEqual([]); + expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalled(); + }); + + it('returns formatted error responses', async () => { + savedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + attributes: {}, + error: { + error: 'Internal Server Error', + message: 'Unexpected bulk response [400]', + statusCode: 500, + }, + id: '0dc73480-5664-11ec-af96-8349972169c7', + references: [], + type: 'exception-list', + }, + ], + }); + + const response = await bulkUpdateImportedItems({ + itemsToUpdate: sampleItems, + savedObjectsClient, + }); + + expect(response).toEqual([ + { + error: { + message: 'Unexpected bulk response [400]', + status_code: 500, + }, + item_id: '(unknown item_id)', + }, + ]); + expect(savedObjectsClient.bulkUpdate).toHaveBeenCalled(); + }); + + it('returns formatted success responses', async () => { + savedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + attributes: { + description: 'some description', + name: 'Query with a rule id', + tags: [], + type: 'detection', + updated_by: 'elastic', + }, + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + namespaces: ['default'], + references: [], + type: 'exception-list', + updated_at: '2021-12-06T07:35:27.941Z', + version: 'WzE0MTc5MiwxXQ==', + }, + ], + }); + + const response = await bulkUpdateImportedItems({ + itemsToUpdate: sampleItems, + savedObjectsClient, + }); + + expect(response).toEqual([ + { + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + status_code: 200, + }, + ]); + expect(savedObjectsClient.bulkUpdate).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_items.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_items.ts new file mode 100644 index 0000000000000..049f93ffe700b --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_items.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 { has } from 'lodash/fp'; +import { SavedObjectsBulkUpdateObject, SavedObjectsClientContract } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects/exceptions_list_so_schema'; +import { ImportResponse } from '../../import_exception_list_and_items'; + +export const bulkUpdateImportedItems = async ({ + itemsToUpdate, + savedObjectsClient, +}: { + itemsToUpdate: Array>; + savedObjectsClient: SavedObjectsClientContract; +}): Promise => { + if (!itemsToUpdate.length) { + return []; + } + + const bulkUpdateResponses = await savedObjectsClient.bulkUpdate(itemsToUpdate); + + return bulkUpdateResponses.saved_objects.map((so) => { + if (has('error', so) && so.error != null) { + return { + error: { + message: so.error.message, + status_code: so.error.statusCode ?? 400, + }, + item_id: '(unknown item_id)', + }; + } else { + return { + id: so.id, + status_code: 200, + }; + } + }); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_lists.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_lists.test.ts new file mode 100644 index 0000000000000..16659e2c547cb --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_lists.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsBulkUpdateObject, SavedObjectsClientContract } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects/exceptions_list_so_schema'; +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; + +import { bulkUpdateImportedLists } from './bulk_update_imported_lists'; + +describe('bulkUpdateImportedLists', () => { + const sampleLists: Array> = [ + { + attributes: { + description: 'updated description', + meta: undefined, + name: 'updated list', + tags: [], + type: 'detection', + updated_by: 'elastic', + }, + id: '1234', + type: 'exception-list', + }, + ]; + let savedObjectsClient: jest.Mocked; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('returns empty array if no lists to create', async () => { + const response = await bulkUpdateImportedLists({ + listsToUpdate: [], + savedObjectsClient, + }); + + expect(response).toEqual([]); + expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalled(); + }); + + it('returns formatted error responses', async () => { + savedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + attributes: {}, + error: { + error: 'Internal Server Error', + message: 'Unexpected bulk response [400]', + statusCode: 500, + }, + id: '0dc73480-5664-11ec-af96-8349972169c7', + references: [], + type: 'exception-list', + }, + ], + }); + + const response = await bulkUpdateImportedLists({ + listsToUpdate: sampleLists, + savedObjectsClient, + }); + + expect(response).toEqual([ + { + error: { + message: 'Unexpected bulk response [400]', + status_code: 500, + }, + list_id: '(unknown list_id)', + }, + ]); + expect(savedObjectsClient.bulkUpdate).toHaveBeenCalled(); + }); + + it('returns formatted success responses', async () => { + savedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + attributes: { + description: 'some description', + name: 'Query with a rule id', + tags: [], + type: 'detection', + updated_by: 'elastic', + }, + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + namespaces: ['default'], + references: [], + type: 'exception-list', + updated_at: '2021-12-06T07:35:27.941Z', + version: 'WzE0MTc5MiwxXQ==', + }, + ], + }); + + const response = await bulkUpdateImportedLists({ + listsToUpdate: sampleLists, + savedObjectsClient, + }); + + expect(response).toEqual([ + { + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + status_code: 200, + }, + ]); + expect(savedObjectsClient.bulkUpdate).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_lists.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_lists.ts new file mode 100644 index 0000000000000..003ce9d2b029b --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_update_imported_lists.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 { has } from 'lodash/fp'; +import { SavedObjectsBulkUpdateObject, SavedObjectsClientContract } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects/exceptions_list_so_schema'; +import { ImportResponse } from '../../import_exception_list_and_items'; + +export const bulkUpdateImportedLists = async ({ + listsToUpdate, + savedObjectsClient, +}: { + listsToUpdate: Array>; + savedObjectsClient: SavedObjectsClientContract; +}): Promise => { + if (!listsToUpdate.length) { + return []; + } + + const bulkUpdateResponses = await savedObjectsClient.bulkUpdate(listsToUpdate); + + return bulkUpdateResponses.saved_objects.map((so) => { + if (has('error', so) && so.error != null) { + return { + error: { + message: so.error.message, + status_code: so.error.statusCode ?? 400, + }, + list_id: '(unknown list_id)', + }; + } else { + return { + id: so.id, + status_code: 200, + }; + } + }); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.test.ts new file mode 100644 index 0000000000000..684be2de2e030 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.test.ts @@ -0,0 +1,341 @@ +/* + * Copyright 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 { Readable } from 'stream'; + +import { createPromiseFromStreams } from '@kbn/utils'; +import { + ImportExceptionListItemSchema, + ImportExceptionsListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; + +import { + getImportExceptionsListItemSchemaDecodedMock, + getImportExceptionsListItemSchemaMock, + getImportExceptionsListSchemaDecodedMock, + getImportExceptionsListSchemaMock, +} from '../../../../../common/schemas/request/import_exceptions_schema.mock'; +import { PromiseStream } from '../../import_exception_list_and_items'; + +import { + createExceptionsStreamFromNdjson, + exceptionsChecksFromArray, +} from './create_exceptions_stream_logic'; + +describe('create_exceptions_stream_logic', () => { + describe('exceptionsChecksFromArray', () => { + it('sorts the items and lists', () => { + const result = exceptionsChecksFromArray( + [ + getImportExceptionsListItemSchemaMock('2'), + getImportExceptionsListSchemaMock(), + getImportExceptionsListItemSchemaMock('1'), + ], + 100 + ); + + expect(result).toEqual({ + items: [ + getImportExceptionsListItemSchemaDecodedMock('2'), + getImportExceptionsListItemSchemaDecodedMock('1'), + ], + lists: [getImportExceptionsListSchemaDecodedMock()], + }); + }); + + it('reports if trying to import more than max allowed number', () => { + expect(() => + exceptionsChecksFromArray( + [ + getImportExceptionsListItemSchemaMock('2'), + getImportExceptionsListSchemaMock(), + getImportExceptionsListItemSchemaMock('1'), + ], + 1 + ) + ).toThrowErrorMatchingInlineSnapshot(`"Can't import more than 1 exceptions"`); + }); + + describe('items validation', () => { + it('reports when an item is missing "item_id"', () => { + const item: Partial> = + getImportExceptionsListItemSchemaMock(); + delete item.item_id; + + // Typescript won, and couldn't get it to accept + // a new value (undefined) for item_id + const result = exceptionsChecksFromArray([item as ImportExceptionListItemSchema], 100); + + expect(result).toEqual({ + items: [new Error('Invalid value "undefined" supplied to "item_id"')], + lists: [], + }); + }); + + it('reports when an item is missing "entries"', () => { + const item: Partial> = + getImportExceptionsListItemSchemaMock(); + delete item.entries; + + // Typescript won, and couldn't get it to accept + // a new value (undefined) for entries + const result = exceptionsChecksFromArray([item as ImportExceptionListItemSchema], 100); + + expect(result).toEqual({ + items: [new Error('Invalid value "undefined" supplied to "entries"')], + lists: [], + }); + }); + + it('does not error if item includes an id, is ignored', () => { + const item: ImportExceptionListItemSchema = { + ...getImportExceptionsListItemSchemaMock(), + id: '123', + }; + + const result = exceptionsChecksFromArray([item], 100); + + expect(result).toEqual({ + items: [{ ...getImportExceptionsListItemSchemaDecodedMock(), id: '123' }], + lists: [], + }); + }); + }); + + describe('lists validation', () => { + it('reports when an item is missing "item_id"', () => { + const list: Partial> = + getImportExceptionsListSchemaMock(); + delete list.list_id; + + // Typescript won, and couldn't get it to accept + // a new value (undefined) for list_id + const result = exceptionsChecksFromArray([list as ImportExceptionsListSchema], 100); + + expect(result).toEqual({ + items: [], + lists: [new Error('Invalid value "undefined" supplied to "list_id"')], + }); + }); + + it('does not error if list includes an id, is ignored', () => { + const list = { ...getImportExceptionsListSchemaMock(), id: '123' }; + + const result = exceptionsChecksFromArray([list], 100); + + expect(result).toEqual({ + items: [], + lists: [{ ...getImportExceptionsListSchemaDecodedMock(), id: '123' }], + }); + }); + }); + }); + + describe('createExceptionsStreamFromNdjson', () => { + it('filters out empty strings', async () => { + const ndJsonStream = new Readable({ + read(): void { + this.push(' '); + this.push(`${JSON.stringify(getImportExceptionsListSchemaMock())}\n`); + this.push(''); + this.push(`${JSON.stringify(getImportExceptionsListItemSchemaMock())}\n`); + this.push(null); + }, + }); + const result = await createPromiseFromStreams([ + ndJsonStream, + ...createExceptionsStreamFromNdjson(100), + ]); + + expect(result).toEqual([ + { + items: [getImportExceptionsListItemSchemaDecodedMock()], + lists: [getImportExceptionsListSchemaDecodedMock()], + }, + ]); + }); + + it('filters out count metadata', async () => { + const ndJsonStream = new Readable({ + read(): void { + this.push(`${JSON.stringify(getImportExceptionsListSchemaMock())}\n`); + this.push( + `${JSON.stringify({ + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + })}\n` + ); + this.push(`${JSON.stringify(getImportExceptionsListItemSchemaMock())}\n`); + this.push(null); + }, + }); + const result = await createPromiseFromStreams([ + ndJsonStream, + ...createExceptionsStreamFromNdjson(100), + ]); + + expect(result).toEqual([ + { + items: [getImportExceptionsListItemSchemaDecodedMock()], + lists: [getImportExceptionsListSchemaDecodedMock()], + }, + ]); + }); + + it('sorts the items and lists', async () => { + const ndJsonStream = new Readable({ + read(): void { + this.push(`${JSON.stringify(getImportExceptionsListItemSchemaMock('2'))}\n`); + this.push(`${JSON.stringify(getImportExceptionsListSchemaMock())}\n`); + this.push(`${JSON.stringify(getImportExceptionsListItemSchemaMock('1'))}\n`); + this.push(null); + }, + }); + const result = await createPromiseFromStreams([ + ndJsonStream, + ...createExceptionsStreamFromNdjson(100), + ]); + + expect(result).toEqual([ + { + items: [ + getImportExceptionsListItemSchemaDecodedMock('2'), + getImportExceptionsListItemSchemaDecodedMock('1'), + ], + lists: [getImportExceptionsListSchemaDecodedMock()], + }, + ]); + }); + + describe('items validation', () => { + it('reports when an item is missing "item_id"', async () => { + const item: Partial> = + getImportExceptionsListItemSchemaMock(); + delete item.item_id; + + const ndJsonStream = new Readable({ + read(): void { + this.push(`${JSON.stringify(item)}\n`); + this.push(null); + }, + }); + const result = await createPromiseFromStreams([ + ndJsonStream, + ...createExceptionsStreamFromNdjson(100), + ]); + + expect(result).toEqual([ + { + items: [new Error('Invalid value "undefined" supplied to "item_id"')], + lists: [], + }, + ]); + }); + + it('reports when an item is missing "entries"', async () => { + const item: Partial> = + getImportExceptionsListItemSchemaMock(); + delete item.entries; + + const ndJsonStream = new Readable({ + read(): void { + this.push(`${JSON.stringify(item)}\n`); + this.push(null); + }, + }); + const result = await createPromiseFromStreams([ + ndJsonStream, + ...createExceptionsStreamFromNdjson(100), + ]); + + expect(result).toEqual([ + { + items: [new Error('Invalid value "undefined" supplied to "entries"')], + lists: [], + }, + ]); + }); + + it('does not error if item includes an id, is ignored', async () => { + const item = { ...getImportExceptionsListItemSchemaMock(), id: '123' }; + + const ndJsonStream = new Readable({ + read(): void { + this.push(`${JSON.stringify(item)}\n`); + this.push(null); + }, + }); + const result = await createPromiseFromStreams([ + ndJsonStream, + ...createExceptionsStreamFromNdjson(100), + ]); + + expect(result).toEqual([ + { + items: [{ ...getImportExceptionsListItemSchemaDecodedMock(), id: '123' }], + lists: [], + }, + ]); + }); + }); + + describe('lists validation', () => { + it('reports when an item is missing "item_id"', async () => { + const list: Partial> = + getImportExceptionsListSchemaMock(); + delete list.list_id; + + const ndJsonStream = new Readable({ + read(): void { + this.push(`${JSON.stringify(list)}\n`); + this.push(null); + }, + }); + const result = await createPromiseFromStreams([ + ndJsonStream, + ...createExceptionsStreamFromNdjson(100), + ]); + + expect(result).toEqual([ + { + items: [], + lists: [new Error('Invalid value "undefined" supplied to "list_id"')], + }, + ]); + }); + + it('does not error if list includes an id, is ignored', async () => { + const list: ImportExceptionsListSchema = { + ...getImportExceptionsListSchemaMock(), + id: '123', + }; + + const ndJsonStream = new Readable({ + read(): void { + this.push(`${JSON.stringify(list)}\n`); + this.push(null); + }, + }); + const result = await createPromiseFromStreams([ + ndJsonStream, + ...createExceptionsStreamFromNdjson(100), + ]); + + expect(result).toEqual([ + { + items: [], + lists: [{ ...getImportExceptionsListSchemaDecodedMock(), id: '123' }], + }, + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.ts new file mode 100644 index 0000000000000..af39936b26142 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.ts @@ -0,0 +1,326 @@ +/* + * Copyright 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 { Transform } from 'stream'; + +import * as t from 'io-ts'; +import { has } from 'lodash/fp'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { + ExportExceptionDetails, + ImportExceptionListItemSchema, + ImportExceptionListItemSchemaDecoded, + ImportExceptionListSchemaDecoded, + ImportExceptionsListSchema, + importExceptionListItemSchema, + importExceptionsListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + createConcatStream, + createFilterStream, + createMapStream, + createReduceStream, + createSplitStream, +} from '@kbn/utils'; +import { BadRequestError } from '@kbn/securitysolution-es-utils'; +import { exactCheck, formatErrors } from '@kbn/securitysolution-io-ts-utils'; + +import { ExceptionsImport } from '../../import_exception_list_and_items'; + +/** + * Parses strings from ndjson stream + */ +export const parseNdjsonStrings = (): Transform => { + return createMapStream((ndJsonStr: string): Transform => { + try { + return JSON.parse(ndJsonStr); + } catch (err) { + return err; + } + }); +}; + +/** + * + * Sorting of exceptions logic + * + */ + +/** + * Helper to determine if exception shape is that of an item vs parent container + * @param exception + * @returns {boolean} + */ +export const isImportExceptionListItemSchema = ( + exception: ImportExceptionListItemSchema | ImportExceptionsListSchema +): exception is ImportExceptionListItemSchema => { + return has('entries', exception) || has('item_id', exception); +}; + +/** + * Sorts the exceptions into the lists and items. + * We do this because we don't want the order of the exceptions + * in the import to matter. If we didn't sort, then some items + * might error if the list has not yet been created + * @param exceptions {array} - exceptions to import + * @returns {stream} incoming exceptions sorted into lists and items + */ +export const sortExceptions = ( + exceptions: ExceptionsImport +): { + items: ImportExceptionListItemSchema[]; + lists: ImportExceptionsListSchema[]; +} => { + return exceptions.reduce<{ + items: ImportExceptionListItemSchema[]; + lists: ImportExceptionsListSchema[]; + }>( + (acc, exception) => { + if (isImportExceptionListItemSchema(exception)) { + return { ...acc, items: [...acc.items, exception] }; + } else { + return { ...acc, lists: [...acc.lists, exception] }; + } + }, + { + items: [], + lists: [], + } + ); +}; + +/** + * Sorts the exceptions into the lists and items. + * We do this because we don't want the order of the exceptions + * in the import to matter. If we didn't sort, then some items + * might error if the list has not yet been created + * @returns {stream} incoming exceptions sorted into lists and items + */ +export const sortExceptionsStream = (): Transform => { + return createReduceStream<{ + items: Array; + lists: Array; + }>( + (acc, exception) => { + if (has('entries', exception) || has('item_id', exception)) { + return { ...acc, items: [...acc.items, exception] }; + } else { + return { ...acc, lists: [...acc.lists, exception] }; + } + }, + { + items: [], + lists: [], + } + ); +}; + +/** + * + * Validating exceptions logic + * + */ + +/** + * Validates exception lists and items schemas incoming as stream + * @returns {stream} validated lists and items + */ +export const validateExceptionsStream = (): Transform => { + return createMapStream<{ + items: Array; + lists: Array; + }>((exceptions) => ({ + items: validateExceptionsItems(exceptions.items), + lists: validateExceptionsLists(exceptions.lists), + })); +}; + +/** + * Validates exception lists and items schemas incoming as array + * @param exceptions {array} - exceptions to import sorted by list/item + * @returns {object} validated lists and items + */ +export const validateExceptions = (exceptions: { + items: Array; + lists: Array; +}): { + items: Array; + lists: Array; +} => { + return { + items: validateExceptionsItems(exceptions.items), + lists: validateExceptionsLists(exceptions.lists), + }; +}; + +/** + * Validates exception lists incoming as array + * @param lists {array} - exception lists to import + * @returns {array} validated exception lists and validation errors + */ +export const validateExceptionsLists = ( + lists: Array +): Array => { + const onLeft = (errors: t.Errors): BadRequestError | ImportExceptionListSchemaDecoded => { + return new BadRequestError(formatErrors(errors).join()); + }; + const onRight = ( + schemaList: ImportExceptionsListSchema + ): BadRequestError | ImportExceptionListSchemaDecoded => { + return schemaList as ImportExceptionListSchemaDecoded; + }; + + return lists.map((obj: ImportExceptionsListSchema | Error) => { + if (!(obj instanceof Error)) { + const decodedList = importExceptionsListSchema.decode(obj); + const checkedList = exactCheck(obj, decodedList); + + return pipe(checkedList, fold(onLeft, onRight)); + } else { + return obj; + } + }); +}; + +/** + * Validates exception items incoming as array + * @param items {array} - exception items to import + * @returns {array} validated exception items and validation errors + */ +export const validateExceptionsItems = ( + items: Array +): Array => { + const onLeft = (errors: t.Errors): BadRequestError | ImportExceptionListItemSchemaDecoded => { + return new BadRequestError(formatErrors(errors).join()); + }; + const onRight = ( + itemSchema: ImportExceptionListItemSchema + ): BadRequestError | ImportExceptionListItemSchemaDecoded => { + return itemSchema as ImportExceptionListItemSchemaDecoded; + }; + + return items.map((item: ImportExceptionListItemSchema | Error) => { + if (!(item instanceof Error)) { + const decodedItem = importExceptionListItemSchema.decode(item); + const checkedItem = exactCheck(item, decodedItem); + + return pipe(checkedItem, fold(onLeft, onRight)); + } else { + return item; + } + }); +}; + +/** + * + * Validating import limits logic + * + */ + +/** + * Validates max number of exceptions allowed to import + * @param limit {number} - max number of exceptions allowed to import + * @returns {array} validated exception items and validation errors + */ +export const checkLimits = (limit: number): ((arg: ExceptionsImport) => ExceptionsImport) => { + return (exceptions: ExceptionsImport): ExceptionsImport => { + if (exceptions.length >= limit) { + throw new Error(`Can't import more than ${limit} exceptions`); + } + + return exceptions; + }; +}; + +/** + * Validates max number of exceptions allowed to import + * Adaptation from: saved_objects/import/create_limit_stream.ts + * @param limit {number} - max number of exceptions allowed to import + * @returns {stream} + */ +export const createLimitStream = (limit: number): Transform => { + return new Transform({ + objectMode: true, + async transform(obj, _, done): Promise { + if (obj.lists.length + obj.items.length >= limit) { + done(new Error(`Can't import more than ${limit} exceptions`)); + } else { + done(undefined, obj); + } + }, + }); +}; + +/** + * + * Filters + * + */ + +/** + * Filters out the counts metadata added on export + */ +export const filterExportedCounts = (): Transform => { + return createFilterStream< + ImportExceptionListSchemaDecoded | ImportExceptionListItemSchemaDecoded | ExportExceptionDetails + >((obj) => obj != null && !has('exported_exception_list_count', obj)); +}; + +/** + * Filters out empty strings from ndjson stream + */ +export const filterEmptyStrings = (): Transform => { + return createFilterStream((ndJsonStr) => ndJsonStr.trim() !== ''); +}; + +/** + * + * Set of helpers to run exceptions through on import + * + */ + +/** + * Takes an array of exceptions and runs it through a set of helpers + * to check max number of exceptions, the shape of the data and sorts + * it into items and lists + * @param exceptionsToImport {array} - exceptions to be imported + * @param exceptionsLimit {number} - max nuber of exception allowed to import + * @returns {object} sorted items and lists + */ +export const exceptionsChecksFromArray = ( + exceptionsToImport: Array, + exceptionsLimit: number +): { + items: Array; + lists: Array; +} => { + return pipe(exceptionsToImport, checkLimits(exceptionsLimit), sortExceptions, validateExceptions); +}; + +/** + * Takes an array of exceptions and runs it through a set of helpers + * to check max number of exceptions, the shape of the data and sorts + * it into items and lists + * Inspiration and the pattern of code followed is from: + * saved_objects/lib/create_saved_objects_stream_from_ndjson.ts + * @param exceptionsToImport {array} - exceptions to be imported + * @param exceptionsLimit {number} - max nuber of exception allowed to import + * @returns {object} sorted items and lists + */ +export const createExceptionsStreamFromNdjson = (exceptionsLimit: number): Transform[] => { + return [ + createSplitStream('\n'), + filterEmptyStrings(), + parseNdjsonStrings(), + filterExportedCounts(), + sortExceptionsStream(), + validateExceptionsStream(), + createLimitStream(exceptionsLimit), + createConcatStream([]), + ]; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.test.ts new file mode 100644 index 0000000000000..9e0c07268aafb --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.test.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 { getImportExceptionsListItemSchemaDecodedMock } from '../../../../../common/schemas/request/import_exceptions_schema.mock'; + +import { getTupleErrorsAndUniqueExceptionListItems } from './dedupe_incoming_items'; + +describe('getTupleErrorsAndUniqueExceptionListItems', () => { + it('reports duplicate item_ids', () => { + const results = getTupleErrorsAndUniqueExceptionListItems([ + getImportExceptionsListItemSchemaDecodedMock(), + getImportExceptionsListItemSchemaDecodedMock(), + ]); + expect(results).toEqual([ + [ + { + error: { + message: + 'More than one exception list item with item_id: "item_id_1" found in imports. The last item will be used.', + status_code: 400, + }, + item_id: 'item_id_1', + }, + ], + [getImportExceptionsListItemSchemaDecodedMock()], + ]); + }); + + it('does not report duplicates if non exist', () => { + const results = getTupleErrorsAndUniqueExceptionListItems([ + getImportExceptionsListItemSchemaDecodedMock('1'), + getImportExceptionsListItemSchemaDecodedMock('2'), + ]); + expect(results).toEqual([ + [], + [ + getImportExceptionsListItemSchemaDecodedMock('1'), + getImportExceptionsListItemSchemaDecodedMock('2'), + ], + ]); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.ts new file mode 100644 index 0000000000000..40d18eebae3d7 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import { + BulkErrorSchema, + ImportExceptionListItemSchemaDecoded, +} from '@kbn/securitysolution-io-ts-list-types'; + +/** + * Reports on duplicates and returns unique array of items + * @param items - exception items to be checked for duplicate list_ids + * @returns {Array} tuple of errors and unique items + */ +export const getTupleErrorsAndUniqueExceptionListItems = ( + items: Array +): [BulkErrorSchema[], ImportExceptionListItemSchemaDecoded[]] => { + const { errors, itemsAcc } = items.reduce( + (acc, parsedExceptionItem) => { + if (parsedExceptionItem instanceof Error) { + acc.errors.set(uuid.v4(), { + error: { + message: `Error found importing exception list item: ${parsedExceptionItem.message}`, + status_code: 400, + }, + list_id: '(unknown item_id)', + }); + } else { + const { item_id: itemId, list_id: listId } = parsedExceptionItem; + if (acc.itemsAcc.has(`${itemId}${listId}`)) { + acc.errors.set(uuid.v4(), { + error: { + message: `More than one exception list item with item_id: "${itemId}" found in imports. The last item will be used.`, + status_code: 400, + }, + item_id: itemId, + }); + } + acc.itemsAcc.set(`${itemId}${listId}`, parsedExceptionItem); + } + + return acc; + }, // using map (preserves ordering) + { + errors: new Map(), + itemsAcc: new Map(), + } + ); + + return [Array.from(errors.values()), Array.from(itemsAcc.values())]; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_lists.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_lists.test.ts new file mode 100644 index 0000000000000..b5796ca17ef45 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_lists.test.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 { getImportExceptionsListSchemaDecodedMock } from '../../../../../common/schemas/request/import_exceptions_schema.mock'; + +import { getTupleErrorsAndUniqueExceptionLists } from './dedupe_incoming_lists'; + +describe('getTupleErrorsAndUniqueExceptionLists', () => { + it('reports duplicate list_ids', () => { + const results = getTupleErrorsAndUniqueExceptionLists([ + getImportExceptionsListSchemaDecodedMock(), + getImportExceptionsListSchemaDecodedMock(), + ]); + expect(results).toEqual([ + [ + { + error: { + message: + 'More than one exception list with list_id: "detection_list_id" found in imports. The last list will be used.', + status_code: 400, + }, + list_id: 'detection_list_id', + }, + ], + [getImportExceptionsListSchemaDecodedMock()], + ]); + }); + + it('does not report duplicates if non exist', () => { + const results = getTupleErrorsAndUniqueExceptionLists([ + getImportExceptionsListSchemaDecodedMock('1'), + getImportExceptionsListSchemaDecodedMock('2'), + ]); + expect(results).toEqual([ + [], + [ + getImportExceptionsListSchemaDecodedMock('1'), + getImportExceptionsListSchemaDecodedMock('2'), + ], + ]); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_lists.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_lists.ts new file mode 100644 index 0000000000000..96adeb492f30d --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_lists.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import { + BulkErrorSchema, + ImportExceptionListSchemaDecoded, +} from '@kbn/securitysolution-io-ts-list-types'; + +/** + * Reports on duplicates and returns unique array of lists + * @param lists - exception lists to be checked for duplicate list_ids + * @returns {Array} tuple of duplicate errors and unique lists + */ +export const getTupleErrorsAndUniqueExceptionLists = ( + lists: Array +): [BulkErrorSchema[], ImportExceptionListSchemaDecoded[]] => { + const { errors, listsAcc } = lists.reduce( + (acc, parsedExceptionList) => { + if (parsedExceptionList instanceof Error) { + acc.errors.set(uuid.v4(), { + error: { + message: `Error found importing exception list: ${parsedExceptionList.message}`, + status_code: 400, + }, + list_id: '(unknown list_id)', + }); + } else { + const { list_id: listId } = parsedExceptionList; + if (acc.listsAcc.has(listId)) { + acc.errors.set(uuid.v4(), { + error: { + message: `More than one exception list with list_id: "${listId}" found in imports. The last list will be used.`, + status_code: 400, + }, + list_id: listId, + }); + } + acc.listsAcc.set(listId, parsedExceptionList); + } + + return acc; + }, // using map (preserves ordering) + { + errors: new Map(), + listsAcc: new Map(), + } + ); + + return [Array.from(errors.values()), Array.from(listsAcc.values())]; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/delete_list_items_to_overwrite.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/delete_list_items_to_overwrite.test.ts new file mode 100644 index 0000000000000..cc7914d219550 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/delete_list_items_to_overwrite.test.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 { SavedObjectsClientContract } from 'kibana/server'; +import { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; + +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { deleteExceptionListItemByList } from '../../delete_exception_list_items_by_list'; + +import { deleteListItemsToBeOverwritten } from './delete_list_items_to_overwrite'; + +jest.mock('../../delete_exception_list_items_by_list'); + +describe('deleteListItemsToBeOverwritten', () => { + const sampleListItemsToDelete: Array<[string, NamespaceType]> = [ + ['list-id', 'single'], + ['list-id-2', 'agnostic'], + ]; + let savedObjectsClient: jest.Mocked; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('returns empty array if no items to create', async () => { + await deleteListItemsToBeOverwritten({ + listsOfItemsToDelete: sampleListItemsToDelete, + savedObjectsClient, + }); + + expect(deleteExceptionListItemByList).toHaveBeenNthCalledWith(1, { + listId: 'list-id', + namespaceType: 'single', + savedObjectsClient, + }); + expect(deleteExceptionListItemByList).toHaveBeenNthCalledWith(2, { + listId: 'list-id-2', + namespaceType: 'agnostic', + savedObjectsClient, + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/delete_list_items_to_overwrite.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/delete_list_items_to_overwrite.ts new file mode 100644 index 0000000000000..f2b3afabcac42 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/delete_list_items_to_overwrite.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; +import { SavedObjectsClientContract } from 'kibana/server'; + +import { deleteExceptionListItemByList } from '../../delete_exception_list_items_by_list'; + +/** + * Helper to delete list items of exception lists to be updated + * as a result of user selecting to overwrite + * @param listsOfItemsToDelete {array} - information needed to delete exception list items + * @param savedObjectsClient {object} + * @returns {array} returns array of success and error formatted responses + */ +export const deleteListItemsToBeOverwritten = async ({ + listsOfItemsToDelete, + savedObjectsClient, +}: { + listsOfItemsToDelete: Array<[string, NamespaceType]>; + savedObjectsClient: SavedObjectsClientContract; +}): Promise => { + for await (const list of listsOfItemsToDelete) { + await deleteExceptionListItemByList({ + listId: list[0], + namespaceType: list[1], + savedObjectsClient, + }); + } +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.test.ts new file mode 100644 index 0000000000000..445031e6c105a --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.test.ts @@ -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 { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import type { SavedObjectsClientContract } from '../../../../../../../../src/core/server'; +import { getImportExceptionsListItemSchemaDecodedMock } from '../../../../../common/schemas/request/import_exceptions_schema.mock'; + +import { + findAllListItemTypes, + getAllListItemTypes, + getItemsFilter, +} from './find_all_exception_list_item_types'; + +describe('find_all_exception_list_item_types', () => { + let savedObjectsClient: jest.Mocked; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + }); + + describe('getItemsFilter', () => { + it('formats agnostic filter', () => { + const result = getItemsFilter({ + namespaceType: 'agnostic', + objects: [ + getImportExceptionsListItemSchemaDecodedMock('1'), + getImportExceptionsListItemSchemaDecodedMock('2'), + ], + }); + + expect(result).toEqual('exception-list-agnostic.attributes.item_id:(1 OR 2)'); + }); + + it('formats single filter', () => { + const result = getItemsFilter({ + namespaceType: 'single', + objects: [ + getImportExceptionsListItemSchemaDecodedMock('1'), + getImportExceptionsListItemSchemaDecodedMock('2'), + ], + }); + + expect(result).toEqual('exception-list.attributes.item_id:(1 OR 2)'); + }); + }); + + describe('findAllListItemTypes', () => { + it('returns null if no items to find', async () => { + const result = await findAllListItemTypes([], [], savedObjectsClient); + + expect(result).toBeNull(); + }); + + it('searches for agnostic items if no non agnostic items passed in', async () => { + await findAllListItemTypes( + [{ ...getImportExceptionsListItemSchemaDecodedMock('1'), namespace_type: 'agnostic' }], + [], + savedObjectsClient + ); + + expect(savedObjectsClient.find).toHaveBeenCalledWith({ + filter: + '((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "detection_list_id") AND exception-list-agnostic.attributes.item_id:(1))', + page: undefined, + perPage: 100, + sortField: undefined, + sortOrder: undefined, + type: ['exception-list-agnostic'], + }); + }); + + it('searches for non agnostic items if no agnostic items passed in', async () => { + await findAllListItemTypes( + [], + [{ ...getImportExceptionsListItemSchemaDecodedMock('1'), namespace_type: 'single' }], + savedObjectsClient + ); + + expect(savedObjectsClient.find).toHaveBeenCalledWith({ + filter: + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "detection_list_id") AND exception-list.attributes.item_id:(1))', + page: undefined, + perPage: 100, + sortField: undefined, + sortOrder: undefined, + type: ['exception-list'], + }); + }); + + it('searches for both agnostic an non agnostic items if some of both passed in', async () => { + await findAllListItemTypes( + [{ ...getImportExceptionsListItemSchemaDecodedMock('1'), namespace_type: 'agnostic' }], + [{ ...getImportExceptionsListItemSchemaDecodedMock('2'), namespace_type: 'single' }], + savedObjectsClient + ); + + expect(savedObjectsClient.find).toHaveBeenCalledWith({ + filter: + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "detection_list_id") AND exception-list.attributes.item_id:(2)) OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "detection_list_id") AND exception-list-agnostic.attributes.item_id:(1))', + page: undefined, + perPage: 100, + sortField: undefined, + sortOrder: undefined, + type: ['exception-list', 'exception-list-agnostic'], + }); + }); + }); + + describe('getAllListItemTypes', () => { + it('returns empty object if no items to find', async () => { + const result = await getAllListItemTypes([], [], savedObjectsClient); + + expect(result).toEqual({}); + }); + + it('returns found items', async () => { + savedObjectsClient.find.mockResolvedValue({ + page: 1, + per_page: 100, + saved_objects: [ + { + attributes: { + description: 'some description', + item_id: 'item-id-1', + name: 'Query with a rule id', + tags: [], + type: 'detection', + updated_by: 'elastic', + }, + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + namespaces: ['default'], + references: [], + score: 1, + type: 'exception-list', + updated_at: '2021-12-06T07:35:27.941Z', + version: 'WzE0MTc5MiwxXQ==', + }, + ], + total: 1, + }); + const result = await getAllListItemTypes( + [{ ...getImportExceptionsListItemSchemaDecodedMock('1'), namespace_type: 'agnostic' }], + [{ ...getImportExceptionsListItemSchemaDecodedMock('2'), namespace_type: 'single' }], + savedObjectsClient + ); + + expect(result).toEqual({ + 'item-id-1': { + _version: 'WzE0MTc5MiwxXQ==', + comments: [], + created_at: undefined, + created_by: undefined, + description: 'some description', + entries: [], + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + item_id: 'item-id-1', + list_id: undefined, + meta: undefined, + name: 'Query with a rule id', + namespace_type: 'single', + os_types: undefined, + tags: [], + tie_breaker_id: undefined, + type: 'simple', + updated_at: '2021-12-06T07:35:27.941Z', + updated_by: 'elastic', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.ts new file mode 100644 index 0000000000000..272c64f161c9c --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.ts @@ -0,0 +1,158 @@ +/* + * Copyright 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 { + ExceptionListItemSchema, + ImportExceptionListItemSchemaDecoded, + NamespaceType, +} from '@kbn/securitysolution-io-ts-list-types'; +import { getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; +import { SavedObjectsClientContract, SavedObjectsFindResponse } from 'kibana/server'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects'; +import { getExceptionListsItemFilter } from '../../find_exception_list_items'; +import { CHUNK_PARSED_OBJECT_SIZE } from '../../import_exception_list_and_items'; +import { transformSavedObjectsToFoundExceptionListItem } from '..'; + +/** + * Helper to build out a filter using item_ids + * @param objects {array} - exception list items to add to filter + * @param savedObjectsClient {object} + * @returns {string} filter + */ +export const getItemsFilter = ({ + objects, + namespaceType, +}: { + objects: ImportExceptionListItemSchemaDecoded[]; + namespaceType: NamespaceType; +}): string => { + return `${ + getSavedObjectTypes({ + namespaceType: [namespaceType], + })[0] + }.attributes.item_id:(${objects.map((item) => item.item_id).join(' OR ')})`; +}; + +/** + * Find exception items that may or may not match an existing item_id + * @param agnosticListItems {array} - items with a namespace of agnostic + * @param nonAgnosticListItems {array} - items with a namespace of single + * @param savedObjectsClient {object} + * @returns {object} results of any found items + */ +export const findAllListItemTypes = async ( + agnosticListItems: ImportExceptionListItemSchemaDecoded[], + nonAgnosticListItems: ImportExceptionListItemSchemaDecoded[], + savedObjectsClient: SavedObjectsClientContract +): Promise | null> => { + // Agnostic filter + const agnosticFilter = getItemsFilter({ + namespaceType: 'agnostic', + objects: agnosticListItems, + }); + + // Non-agnostic filter + const nonAgnosticFilter = getItemsFilter({ + namespaceType: 'single', + objects: nonAgnosticListItems, + }); + + // savedObjectTypes + const savedObjectType = getSavedObjectTypes({ namespaceType: ['single'] }); + const savedObjectTypeAgnostic = getSavedObjectTypes({ namespaceType: ['agnostic'] }); + + if (!agnosticListItems.length && !nonAgnosticListItems.length) { + return null; + } else if (agnosticListItems.length && !nonAgnosticListItems.length) { + return savedObjectsClient.find({ + filter: getExceptionListsItemFilter({ + filter: [agnosticFilter], + listId: agnosticListItems.map(({ list_id: listId }) => listId), + savedObjectType: agnosticListItems.map( + ({ namespace_type: namespaceType }) => + getSavedObjectTypes({ namespaceType: [namespaceType] })[0] + ), + }), + page: undefined, + perPage: CHUNK_PARSED_OBJECT_SIZE, + sortField: undefined, + sortOrder: undefined, + type: savedObjectTypeAgnostic, + }); + } else if (!agnosticListItems.length && nonAgnosticListItems.length) { + return savedObjectsClient.find({ + filter: getExceptionListsItemFilter({ + filter: [nonAgnosticFilter], + listId: nonAgnosticListItems.map(({ list_id: listId }) => listId), + savedObjectType: nonAgnosticListItems.map( + ({ namespace_type: namespaceType }) => + getSavedObjectTypes({ namespaceType: [namespaceType] })[0] + ), + }), + page: undefined, + perPage: CHUNK_PARSED_OBJECT_SIZE, + sortField: undefined, + sortOrder: undefined, + type: savedObjectType, + }); + } else { + const items = [...nonAgnosticListItems, ...agnosticListItems]; + return savedObjectsClient.find({ + filter: getExceptionListsItemFilter({ + filter: [nonAgnosticFilter, agnosticFilter], + listId: items.map(({ list_id: listId }) => listId), + savedObjectType: items.map( + ({ namespace_type: namespaceType }) => + getSavedObjectTypes({ namespaceType: [namespaceType] })[0] + ), + }), + page: undefined, + perPage: CHUNK_PARSED_OBJECT_SIZE, + sortField: undefined, + sortOrder: undefined, + type: [...savedObjectType, ...savedObjectTypeAgnostic], + }); + } +}; + +/** + * Helper to find if any imported items match existing items based on item_id + * @param agnosticListItems {array} - items with a namespace of agnostic + * @param nonAgnosticListItems {array} - items with a namespace of single + * @param savedObjectsClient {object} + * @returns {object} results of any found items + */ +export const getAllListItemTypes = async ( + agnosticListItems: ImportExceptionListItemSchemaDecoded[], + nonAgnosticListItems: ImportExceptionListItemSchemaDecoded[], + savedObjectsClient: SavedObjectsClientContract +): Promise> => { + // Gather items with matching item_id + const foundItemsResponse = await findAllListItemTypes( + agnosticListItems, + nonAgnosticListItems, + savedObjectsClient + ); + + if (foundItemsResponse == null) { + return {}; + } + + const transformedResponse = transformSavedObjectsToFoundExceptionListItem({ + savedObjectsFindResponse: foundItemsResponse, + }); + + // Dictionary of found items + return transformedResponse.data.reduce( + (acc, item) => ({ + ...acc, + [item.item_id]: item, + }), + {} + ); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.test.ts new file mode 100644 index 0000000000000..3103891ad92f6 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.test.ts @@ -0,0 +1,165 @@ +/* + * Copyright 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 { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import type { SavedObjectsClientContract } from '../../../../../../../../src/core/server'; +import { getImportExceptionsListSchemaDecodedMock } from '../../../../../common/schemas/request/import_exceptions_schema.mock'; +import { findExceptionList } from '../../find_exception_list'; + +import { findAllListTypes, getAllListTypes, getListFilter } from './find_all_exception_list_types'; + +jest.mock('../../find_exception_list'); + +describe('find_all_exception_list_item_types', () => { + let savedObjectsClient: jest.Mocked; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + jest.clearAllMocks(); + }); + + describe('getListFilter', () => { + it('formats agnostic filter', () => { + const result = getListFilter({ + namespaceType: 'agnostic', + objects: [ + getImportExceptionsListSchemaDecodedMock('1'), + getImportExceptionsListSchemaDecodedMock('2'), + ], + }); + + expect(result).toEqual('exception-list-agnostic.attributes.list_id:(1 OR 2)'); + }); + + it('formats single filter', () => { + const result = getListFilter({ + namespaceType: 'single', + objects: [ + getImportExceptionsListSchemaDecodedMock('1'), + getImportExceptionsListSchemaDecodedMock('2'), + ], + }); + + expect(result).toEqual('exception-list.attributes.list_id:(1 OR 2)'); + }); + }); + + describe('findAllListTypes', () => { + it('returns null if no lists to find', async () => { + const result = await findAllListTypes([], [], savedObjectsClient); + + expect(result).toBeNull(); + }); + + it('searches for agnostic lists if no non agnostic lists passed in', async () => { + await findAllListTypes( + [{ ...getImportExceptionsListSchemaDecodedMock('1'), namespace_type: 'agnostic' }], + [], + savedObjectsClient + ); + + expect(findExceptionList).toHaveBeenCalledWith({ + filter: 'exception-list-agnostic.attributes.list_id:(1)', + namespaceType: ['agnostic'], + page: undefined, + perPage: 100, + savedObjectsClient, + sortField: undefined, + sortOrder: undefined, + }); + }); + + it('searches for non agnostic lists if no agnostic lists passed in', async () => { + await findAllListTypes( + [], + [{ ...getImportExceptionsListSchemaDecodedMock('1'), namespace_type: 'single' }], + savedObjectsClient + ); + + expect(findExceptionList).toHaveBeenCalledWith({ + filter: 'exception-list.attributes.list_id:(1)', + namespaceType: ['single'], + page: undefined, + perPage: 100, + savedObjectsClient, + sortField: undefined, + sortOrder: undefined, + }); + }); + + it('searches for both agnostic an non agnostic lists if some of both passed in', async () => { + await findAllListTypes( + [{ ...getImportExceptionsListSchemaDecodedMock('1'), namespace_type: 'agnostic' }], + [{ ...getImportExceptionsListSchemaDecodedMock('2'), namespace_type: 'single' }], + savedObjectsClient + ); + + expect(findExceptionList).toHaveBeenCalledWith({ + filter: + 'exception-list-agnostic.attributes.list_id:(1) OR exception-list.attributes.list_id:(2)', + namespaceType: ['single', 'agnostic'], + page: undefined, + perPage: 100, + savedObjectsClient, + sortField: undefined, + sortOrder: undefined, + }); + }); + }); + + describe('getAllListTypes', () => { + it('returns empty object if no items to find', async () => { + const result = await getAllListTypes([], [], savedObjectsClient); + + expect(result).toEqual({}); + }); + + it('returns found items', async () => { + (findExceptionList as jest.Mock).mockResolvedValue({ + data: [ + { + description: 'some description', + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + list_id: '1', + name: 'Query with a rule id', + namespaces: ['default'], + references: [], + tags: [], + type: 'detection', + updated_at: '2021-12-06T07:35:27.941Z', + updated_by: 'elastic', + version: 'WzE0MTc5MiwxXQ==', + }, + ], + page: 1, + per_page: 100, + total: 1, + }); + const result = await getAllListTypes( + [{ ...getImportExceptionsListSchemaDecodedMock('1'), namespace_type: 'agnostic' }], + [{ ...getImportExceptionsListSchemaDecodedMock('2'), namespace_type: 'single' }], + savedObjectsClient + ); + + expect(result).toEqual({ + '1': { + description: 'some description', + id: '14aec120-5667-11ec-ae56-7ddc0e93145f', + list_id: '1', + name: 'Query with a rule id', + namespaces: ['default'], + references: [], + tags: [], + type: 'detection', + updated_at: '2021-12-06T07:35:27.941Z', + updated_by: 'elastic', + version: 'WzE0MTc5MiwxXQ==', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.ts new file mode 100644 index 0000000000000..d98412768ef96 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ExceptionListSchema, + FoundExceptionListSchema, + ImportExceptionListItemSchemaDecoded, + ImportExceptionListSchemaDecoded, + NamespaceType, +} from '@kbn/securitysolution-io-ts-list-types'; +import { getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; +import { SavedObjectsClientContract } from 'kibana/server'; + +import { findExceptionList } from '../../find_exception_list'; +import { CHUNK_PARSED_OBJECT_SIZE } from '../../import_exception_list_and_items'; + +/** + * Helper to build out a filter using list_id + * @param objects {array} - exception lists to add to filter + * @param savedObjectsClient {object} + * @returns {string} filter + */ +export const getListFilter = ({ + objects, + namespaceType, +}: { + objects: ImportExceptionListSchemaDecoded[] | ImportExceptionListItemSchemaDecoded[]; + namespaceType: NamespaceType; +}): string => { + return `${ + getSavedObjectTypes({ + namespaceType: [namespaceType], + })[0] + }.attributes.list_id:(${objects.map((list) => list.list_id).join(' OR ')})`; +}; + +/** + * Find exception lists that may or may not match an existing list_id + * @param agnosticListItems {array} - lists with a namespace of agnostic + * @param nonAgnosticListItems {array} - lists with a namespace of single + * @param savedObjectsClient {object} + * @returns {object} results of any found lists + */ +export const findAllListTypes = async ( + agnosticListItems: ImportExceptionListSchemaDecoded[] | ImportExceptionListItemSchemaDecoded[], + nonAgnosticListItems: ImportExceptionListSchemaDecoded[] | ImportExceptionListItemSchemaDecoded[], + savedObjectsClient: SavedObjectsClientContract +): Promise => { + // Agnostic filter + const agnosticFilter = getListFilter({ + namespaceType: 'agnostic', + objects: agnosticListItems, + }); + + // Non-agnostic filter + const nonAgnosticFilter = getListFilter({ + namespaceType: 'single', + objects: nonAgnosticListItems, + }); + + if (!agnosticListItems.length && !nonAgnosticListItems.length) { + return null; + } else if (agnosticListItems.length && !nonAgnosticListItems.length) { + return findExceptionList({ + filter: agnosticFilter, + namespaceType: ['agnostic'], + page: undefined, + perPage: CHUNK_PARSED_OBJECT_SIZE, + savedObjectsClient, + sortField: undefined, + sortOrder: undefined, + }); + } else if (!agnosticListItems.length && nonAgnosticListItems.length) { + return findExceptionList({ + filter: nonAgnosticFilter, + namespaceType: ['single'], + page: undefined, + perPage: CHUNK_PARSED_OBJECT_SIZE, + savedObjectsClient, + sortField: undefined, + sortOrder: undefined, + }); + } else { + return findExceptionList({ + filter: `${agnosticFilter} OR ${nonAgnosticFilter}`, + namespaceType: ['single', 'agnostic'], + page: undefined, + perPage: CHUNK_PARSED_OBJECT_SIZE, + savedObjectsClient, + sortField: undefined, + sortOrder: undefined, + }); + } +}; + +/** + * Helper to find if any imported lists match existing lists based on list_id + * @param agnosticListItems {array} - lists with a namespace of agnostic + * @param nonAgnosticListItems {array} - lists with a namespace of single + * @param savedObjectsClient {object} + * @returns {object} results of any found lists + */ +export const getAllListTypes = async ( + agnosticListItems: ImportExceptionListSchemaDecoded[] | ImportExceptionListItemSchemaDecoded[], + nonAgnosticListItems: ImportExceptionListSchemaDecoded[] | ImportExceptionListItemSchemaDecoded[], + savedObjectsClient: SavedObjectsClientContract +): Promise> => { + // Gather lists referenced + const foundListsResponse = await findAllListTypes( + agnosticListItems, + nonAgnosticListItems, + savedObjectsClient + ); + + if (foundListsResponse == null) { + return {}; + } + + // Dictionary of found lists + return foundListsResponse.data.reduce( + (acc, list) => ({ + ...acc, + [list.list_id]: list, + }), + {} + ); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/import_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/import_exception_list_items.ts new file mode 100644 index 0000000000000..d96c7eb7e1696 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/import_exception_list_items.ts @@ -0,0 +1,92 @@ +/* + * Copyright 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 { ImportExceptionListItemSchemaDecoded } from '@kbn/securitysolution-io-ts-list-types'; +import { SavedObjectsClientContract } from 'kibana/server'; + +import { ImportDataResponse, ImportResponse } from '../../import_exception_list_and_items'; + +import { getAllListItemTypes } from './find_all_exception_list_item_types'; +import { getAllListTypes } from './find_all_exception_list_types'; +import { sortExceptionItemsToUpdateOrCreate } from './sort_exception_items_to_create_update'; +import { bulkCreateImportedItems } from './bulk_create_imported_items'; +import { bulkUpdateImportedItems } from './bulk_update_imported_items'; +import { sortItemsImportsByNamespace } from './sort_import_by_namespace'; +import { sortImportResponses } from './sort_import_responses'; + +/** + * Helper with logic determining when to create or update on exception list items import + * @param savedObjectsClient + * @param itemsChunks - exception list items being imported + * @param isOverwrite - if matching item_id found, should item be overwritten + * @param user - username + * @returns {Object} returns counts of successful imports and any errors found + */ +export const importExceptionListItems = async ({ + itemsChunks, + isOverwrite, + savedObjectsClient, + user, +}: { + itemsChunks: ImportExceptionListItemSchemaDecoded[][]; + isOverwrite: boolean; + savedObjectsClient: SavedObjectsClientContract; + user: string; +}): Promise => { + let importExceptionListItemsResponse: ImportResponse[] = []; + + for await (const itemsChunk of itemsChunks) { + // sort by namespaceType + const [agnosticListItems, nonAgnosticListItems] = sortItemsImportsByNamespace(itemsChunk); + + // Gather lists referenced by items + // Dictionary of found lists + const foundLists = await getAllListTypes( + agnosticListItems, + nonAgnosticListItems, + savedObjectsClient + ); + + // Find any existing items with matching item_id + // Dictionary of found items + const foundItems = await getAllListItemTypes( + agnosticListItems, + nonAgnosticListItems, + savedObjectsClient + ); + + // Figure out which items need to be bulk created/updated + const { errors, itemsToCreate, itemsToUpdate } = sortExceptionItemsToUpdateOrCreate({ + existingItems: foundItems, + existingLists: foundLists, + isOverwrite, + items: itemsChunk, + user, + }); + + // Items to bulk create + const bulkCreateResponse = await bulkCreateImportedItems({ + itemsToCreate, + savedObjectsClient, + }); + + // Items to bulk update + const bulkUpdateResponse = await bulkUpdateImportedItems({ + itemsToUpdate, + savedObjectsClient, + }); + + importExceptionListItemsResponse = [ + ...importExceptionListItemsResponse, + ...bulkCreateResponse, + ...bulkUpdateResponse, + ...errors, + ]; + } + + return sortImportResponses(importExceptionListItemsResponse); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/import_exception_lists.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/import_exception_lists.ts new file mode 100644 index 0000000000000..d728ff5fb01cb --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/import_exception_lists.ts @@ -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 { ImportExceptionListSchemaDecoded } from '@kbn/securitysolution-io-ts-list-types'; + +import { SavedObjectsClientContract } from '../../../../../../../../src/core/server/'; +import { ImportDataResponse, ImportResponse } from '../../import_exception_list_and_items'; + +import { getAllListTypes } from './find_all_exception_list_types'; +import { sortExceptionListsToUpdateOrCreate } from './sort_exception_lists_to_create_update'; +import { bulkCreateImportedLists } from './bulk_create_imported_lists'; +import { bulkUpdateImportedLists } from './bulk_update_imported_lists'; +import { deleteListItemsToBeOverwritten } from './delete_list_items_to_overwrite'; +import { sortListsImportsByNamespace } from './sort_import_by_namespace'; +import { sortImportResponses } from './sort_import_responses'; +/** + * Helper with logic determining when to create or update on exception list import + * @param exceptionListsClient - exceptions client + * @param listsChunks - exception lists being imported + * @param isOverwrite - if matching lis_id found, should list be overwritten + * @returns {Object} returns counts of successful imports and any errors found + */ +export const importExceptionLists = async ({ + isOverwrite, + listsChunks, + savedObjectsClient, + user, +}: { + isOverwrite: boolean; + listsChunks: ImportExceptionListSchemaDecoded[][]; + savedObjectsClient: SavedObjectsClientContract; + user: string; +}): Promise => { + let importExceptionListsResponse: ImportResponse[] = []; + + for await (const listChunk of listsChunks) { + // sort by namespaceType + const [agnosticLists, nonAgnosticLists] = sortListsImportsByNamespace(listChunk); + + // Gather lists referenced by items + // Dictionary of found lists + const foundLists = await getAllListTypes(agnosticLists, nonAgnosticLists, savedObjectsClient); + + // Figure out what lists to bulk create/update + const { errors, listItemsToDelete, listsToCreate, listsToUpdate } = + sortExceptionListsToUpdateOrCreate({ + existingLists: foundLists, + isOverwrite, + lists: listChunk, + user, + }); + + // lists to bulk create/update + const bulkCreateResponse = await bulkCreateImportedLists({ + listsToCreate, + savedObjectsClient, + }); + // lists that are to be updated where overwrite is true, need to have + // existing items removed. By selecting to overwrite, user selects to + // overwrite entire list + items + await deleteListItemsToBeOverwritten({ + listsOfItemsToDelete: listItemsToDelete, + savedObjectsClient, + }); + + const bulkUpdateResponse = await bulkUpdateImportedLists({ + listsToUpdate, + savedObjectsClient, + }); + + importExceptionListsResponse = [ + ...importExceptionListsResponse, + ...bulkCreateResponse, + ...bulkUpdateResponse, + ...errors, + ]; + } + + return sortImportResponses(importExceptionListsResponse); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/is_import_regular.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/is_import_regular.test.ts new file mode 100644 index 0000000000000..953bcb748f14b --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/is_import_regular.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isImportRegular } from './is_import_regular'; + +describe('isImportRegular', () => { + it('returns true if it has a status_code but no error', () => { + expect( + isImportRegular({ + list_id: '123', + status_code: 200, + }) + ).toBeTruthy(); + }); + + it('returns false if it has error', () => { + expect( + isImportRegular({ + error: { + message: 'error occurred', + status_code: 500, + }, + list_id: '123', + }) + ).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/is_import_regular.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/is_import_regular.ts new file mode 100644 index 0000000000000..d7a3a379deec9 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/is_import_regular.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { has } from 'lodash/fp'; + +import { ImportExceptionsOk, ImportResponse } from '../../import_exception_list_and_items'; + +/** + * Helper to determine if response is error response or not + * @param importExceptionsResponse {array} successful or error responses + * @returns {boolean} + */ +export const isImportRegular = ( + importExceptionsResponse: ImportResponse +): importExceptionsResponse is ImportExceptionsOk => { + return !has('error', importExceptionsResponse) && has('status_code', importExceptionsResponse); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_or_update.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_or_update.test.ts new file mode 100644 index 0000000000000..a4d1e3d0691ce --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_or_update.test.ts @@ -0,0 +1,322 @@ +/* + * Copyright 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 { getImportExceptionsListItemSchemaDecodedMock } from '../../../../../common/schemas/request/import_exceptions_schema.mock'; +import { getExceptionListSchemaMock } from '../../../../../common/schemas/response/exception_list_schema.mock'; +import { getExceptionListItemSchemaMock } from '../../../../../common/schemas/response/exception_list_item_schema.mock'; + +import { sortExceptionItemsToUpdateOrCreate } from './sort_exception_items_to_create_update'; + +jest.mock('uuid', () => ({ + v4: (): string => 'NEW_UUID', +})); + +describe('sort_exception_lists_items_to_create_update', () => { + beforeEach(() => + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('2021-12-07T09:13:51.888Z') + ); + afterAll(() => jest.restoreAllMocks()); + + describe('sortExceptionItemsToUpdateOrCreate', () => { + describe('overwrite is false', () => { + it('assigns error if no matching item list_id found', () => { + const result = sortExceptionItemsToUpdateOrCreate({ + existingItems: {}, + existingLists: {}, + isOverwrite: false, + items: [getImportExceptionsListItemSchemaDecodedMock('item-id-1', 'list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [ + { + error: { + message: + 'Exception list with list_id: "list-id-1", not found for exception list item with item_id: "item-id-1"', + status_code: 409, + }, + item_id: 'item-id-1', + list_id: 'list-id-1', + }, + ], + itemsToCreate: [], + itemsToUpdate: [], + }); + }); + + it('assigns item to be created if no matching item found', () => { + const result = sortExceptionItemsToUpdateOrCreate({ + existingItems: {}, + existingLists: { + 'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' }, + }, + isOverwrite: false, + items: [getImportExceptionsListItemSchemaDecodedMock('item-id-1', 'list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [], + itemsToCreate: [ + { + attributes: { + comments: [], + created_at: '2021-12-07T09:13:51.888Z', + created_by: 'elastic', + description: 'some description', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + immutable: undefined, + item_id: 'item-id-1', + list_id: 'list-id-1', + list_type: 'item', + meta: undefined, + name: 'Query with a rule id', + os_types: [], + tags: [], + tie_breaker_id: 'NEW_UUID', + type: 'simple', + updated_by: 'elastic', + version: undefined, + }, + type: 'exception-list', + }, + ], + itemsToUpdate: [], + }); + }); + + it('assigns error if matching item found', () => { + const result = sortExceptionItemsToUpdateOrCreate({ + existingItems: { + 'item-id-1': { + ...getExceptionListItemSchemaMock({ item_id: 'item-id-1', list_id: 'list-id-1' }), + }, + }, + existingLists: { + 'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' }, + }, + isOverwrite: false, + items: [getImportExceptionsListItemSchemaDecodedMock('item-id-1', 'list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [ + { + error: { + message: + 'Found that item_id: "item-id-1" already exists. Import of item_id: "item-id-1" skipped.', + status_code: 409, + }, + item_id: 'item-id-1', + list_id: 'list-id-1', + }, + ], + itemsToCreate: [], + itemsToUpdate: [], + }); + }); + }); + + describe('overwrite is true', () => { + it('assigns error if no matching item list_id found', () => { + const result = sortExceptionItemsToUpdateOrCreate({ + existingItems: {}, + existingLists: {}, + isOverwrite: true, + items: [getImportExceptionsListItemSchemaDecodedMock('item-id-1', 'list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [ + { + error: { + message: + 'Exception list with list_id: "list-id-1", not found for exception list item with item_id: "item-id-1"', + status_code: 409, + }, + item_id: 'item-id-1', + list_id: 'list-id-1', + }, + ], + itemsToCreate: [], + itemsToUpdate: [], + }); + }); + + it('assigns error if matching item_id found but differing list_id', () => { + const result = sortExceptionItemsToUpdateOrCreate({ + existingItems: { + 'item-id-1': { + ...getExceptionListItemSchemaMock({ item_id: 'item-id-1', list_id: 'list-id-2' }), + }, + }, + existingLists: { + 'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' }, + }, + isOverwrite: true, + items: [getImportExceptionsListItemSchemaDecodedMock('item-id-1', 'list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [ + { + error: { + message: + 'Error trying to update item_id: "item-id-1" and list_id: "list-id-1". The item already exists under list_id: list-id-2', + status_code: 409, + }, + item_id: 'item-id-1', + list_id: 'list-id-1', + }, + ], + itemsToCreate: [], + itemsToUpdate: [], + }); + }); + + it('assigns item to be created if no matching item found', () => { + const result = sortExceptionItemsToUpdateOrCreate({ + existingItems: {}, + existingLists: { + 'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' }, + }, + isOverwrite: true, + items: [getImportExceptionsListItemSchemaDecodedMock('item-id-1', 'list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [], + itemsToCreate: [ + { + attributes: { + comments: [], + created_at: '2021-12-07T09:13:51.888Z', + created_by: 'elastic', + description: 'some description', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + immutable: undefined, + item_id: 'item-id-1', + list_id: 'list-id-1', + list_type: 'item', + meta: undefined, + name: 'Query with a rule id', + os_types: [], + tags: [], + tie_breaker_id: 'NEW_UUID', + type: 'simple', + updated_by: 'elastic', + version: undefined, + }, + type: 'exception-list', + }, + ], + itemsToUpdate: [], + }); + }); + + it('assigns item to be updated if matching item found', () => { + const result = sortExceptionItemsToUpdateOrCreate({ + existingItems: { + 'item-id-1': { + ...getExceptionListItemSchemaMock({ item_id: 'item-id-1', list_id: 'list-id-1' }), + }, + }, + existingLists: { + 'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' }, + }, + isOverwrite: true, + items: [getImportExceptionsListItemSchemaDecodedMock('item-id-1', 'list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [], + itemsToCreate: [], + itemsToUpdate: [ + { + attributes: { + comments: [], + description: 'some description', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + meta: undefined, + name: 'Query with a rule id', + os_types: [], + tags: [], + type: 'simple', + updated_by: 'elastic', + }, + id: '1', + type: 'exception-list', + }, + ], + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_update.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_update.ts new file mode 100644 index 0000000000000..da4884506ef45 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_update.ts @@ -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 uuid from 'uuid'; +import { SavedObjectsBulkCreateObject, SavedObjectsBulkUpdateObject } from 'kibana/server'; +import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; +import { + BulkErrorSchema, + ExceptionListItemSchema, + ExceptionListSchema, + ImportExceptionListItemSchemaDecoded, +} from '@kbn/securitysolution-io-ts-list-types'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects'; +import { transformCreateCommentsToComments, transformUpdateCommentsToComments } from '..'; + +export const sortExceptionItemsToUpdateOrCreate = ({ + items, + existingLists, + existingItems, + isOverwrite, + user, +}: { + items: ImportExceptionListItemSchemaDecoded[]; + existingLists: Record; + existingItems: Record; + isOverwrite: boolean; + user: string; +}): { + errors: BulkErrorSchema[]; + itemsToCreate: Array>; + itemsToUpdate: Array>; +} => { + const results: { + errors: BulkErrorSchema[]; + itemsToCreate: Array>; + itemsToUpdate: Array>; + } = { + errors: [], + itemsToCreate: [], + itemsToUpdate: [], + }; + + for (const chunk of items) { + const { + comments, + description, + entries, + item_id: itemId, + meta, + list_id: listId, + name, + namespace_type: namespaceType, + os_types: osTypes, + tags, + type, + } = chunk; + const dateNow = new Date().toISOString(); + const savedObjectType = getSavedObjectType({ namespaceType }); + + if (existingLists[listId] == null) { + results.errors = [ + ...results.errors, + { + error: { + message: `Exception list with list_id: "${listId}", not found for exception list item with item_id: "${itemId}"`, + status_code: 409, + }, + item_id: itemId, + list_id: listId, + }, + ]; + } else if (existingItems[itemId] == null) { + const transformedComments = transformCreateCommentsToComments({ + incomingComments: comments, + user, + }); + + results.itemsToCreate = [ + ...results.itemsToCreate, + { + attributes: { + comments: transformedComments, + created_at: dateNow, + created_by: user, + description, + entries, + immutable: undefined, + item_id: itemId, + list_id: listId, + list_type: 'item', + meta, + name, + os_types: osTypes, + tags, + tie_breaker_id: uuid.v4(), + type, + updated_by: user, + version: undefined, + }, + type: savedObjectType, + }, + ]; + } else if (existingItems[itemId] != null && isOverwrite) { + if (existingItems[itemId].list_id === listId) { + const transformedComments = transformUpdateCommentsToComments({ + comments, + existingComments: existingItems[itemId].comments, + user, + }); + + results.itemsToUpdate = [ + ...results.itemsToUpdate, + { + attributes: { + comments: transformedComments, + description, + entries, + meta, + name, + os_types: osTypes, + tags, + type, + updated_by: user, + }, + id: existingItems[itemId].id, + type: savedObjectType, + }, + ]; + } else { + // If overwrite is true, the list parent container is deleted first along + // with its items, so to get here would mean the user hit a bit of an odd scenario. + // Sample scenario would be as follows: + // In system we have: + // List A ---> with item list_item_id + // Import is: + // List A ---> with item list_item_id_1 + // List B ---> with item list_item_id_1 + // If we just did an update of the item, we would overwrite + // list_item_id_1 of List A, which would be weird behavior + // What happens: + // List A and items are deleted and recreated + // List B is created, but list_item_id_1 already exists under List A and user warned + results.errors = [ + ...results.errors, + { + error: { + message: `Error trying to update item_id: "${itemId}" and list_id: "${listId}". The item already exists under list_id: ${existingItems[itemId].list_id}`, + status_code: 409, + }, + item_id: itemId, + list_id: listId, + }, + ]; + } + } else if (existingItems[itemId] != null) { + results.errors = [ + ...results.errors, + { + error: { + message: `Found that item_id: "${itemId}" already exists. Import of item_id: "${itemId}" skipped.`, + status_code: 409, + }, + item_id: itemId, + list_id: listId, + }, + ]; + } + } + return results; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_or_update.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_or_update.test.ts new file mode 100644 index 0000000000000..0e47292e7d4e5 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_or_update.test.ts @@ -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 { getImportExceptionsListSchemaDecodedMock } from '../../../../../common/schemas/request/import_exceptions_schema.mock'; +import { getExceptionListSchemaMock } from '../../../../../common/schemas/response/exception_list_schema.mock'; + +import { sortExceptionListsToUpdateOrCreate } from './sort_exception_lists_to_create_update'; + +jest.mock('uuid', () => ({ + v4: (): string => 'NEW_UUID', +})); + +describe('sort_exception_lists_to_create_update', () => { + beforeEach(() => + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('2021-12-07T09:13:51.888Z') + ); + afterAll(() => jest.restoreAllMocks()); + + describe('sortExceptionListsToUpdateOrCreate', () => { + describe('overwrite is false', () => { + it('assigns list to create if its list_id does not match an existing one', () => { + const result = sortExceptionListsToUpdateOrCreate({ + existingLists: {}, + isOverwrite: false, + lists: [getImportExceptionsListSchemaDecodedMock('list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [], + listItemsToDelete: [], + listsToCreate: [ + { + attributes: { + comments: undefined, + created_at: '2021-12-07T09:13:51.888Z', + created_by: 'elastic', + description: 'some description', + entries: undefined, + immutable: false, + item_id: undefined, + list_id: 'list-id-1', + list_type: 'list', + meta: undefined, + name: 'Query with a rule id', + os_types: [], + tags: [], + tie_breaker_id: 'NEW_UUID', + type: 'detection', + updated_by: 'elastic', + version: 1, + }, + type: 'exception-list', + }, + ], + listsToUpdate: [], + }); + }); + + it('assigns error if matching list_id is found', () => { + const result = sortExceptionListsToUpdateOrCreate({ + existingLists: { + 'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' }, + }, + isOverwrite: false, + lists: [getImportExceptionsListSchemaDecodedMock('list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [ + { + error: { + message: + 'Found that list_id: "list-id-1" already exists. Import of list_id: "list-id-1" skipped.', + status_code: 409, + }, + list_id: 'list-id-1', + }, + ], + listItemsToDelete: [], + listsToCreate: [], + listsToUpdate: [], + }); + }); + }); + + describe('overwrite is true', () => { + it('assigns list to be created if its list_id does not match an existing one', () => { + const result = sortExceptionListsToUpdateOrCreate({ + existingLists: {}, + isOverwrite: true, + lists: [getImportExceptionsListSchemaDecodedMock('list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [], + listItemsToDelete: [], + listsToCreate: [ + { + attributes: { + comments: undefined, + created_at: '2021-12-07T09:13:51.888Z', + created_by: 'elastic', + description: 'some description', + entries: undefined, + immutable: false, + item_id: undefined, + list_id: 'list-id-1', + list_type: 'list', + meta: undefined, + name: 'Query with a rule id', + os_types: [], + tags: [], + tie_breaker_id: 'NEW_UUID', + type: 'detection', + updated_by: 'elastic', + version: 1, + }, + type: 'exception-list', + }, + ], + listsToUpdate: [], + }); + }); + + it('assigns list to be updated if its list_id matches an existing one', () => { + const result = sortExceptionListsToUpdateOrCreate({ + existingLists: { + 'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' }, + }, + isOverwrite: true, + lists: [getImportExceptionsListSchemaDecodedMock('list-id-1')], + user: 'elastic', + }); + + expect(result).toEqual({ + errors: [], + listItemsToDelete: [['list-id-1', 'single']], + listsToCreate: [], + listsToUpdate: [ + { + attributes: { + description: 'some description', + meta: undefined, + name: 'Query with a rule id', + tags: [], + type: 'detection', + updated_by: 'elastic', + }, + id: '1', + type: 'exception-list', + }, + ], + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_update.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_update.ts new file mode 100644 index 0000000000000..c27b76004b7f0 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_update.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import { SavedObjectsBulkCreateObject, SavedObjectsBulkUpdateObject } from 'kibana/server'; +import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; +import { + BulkErrorSchema, + ExceptionListSchema, + ImportExceptionListSchemaDecoded, + NamespaceType, +} from '@kbn/securitysolution-io-ts-list-types'; + +import { ExceptionListSoSchema } from '../../../../schemas/saved_objects'; + +export const sortExceptionListsToUpdateOrCreate = ({ + lists, + existingLists, + isOverwrite, + user, +}: { + lists: ImportExceptionListSchemaDecoded[]; + existingLists: Record; + isOverwrite: boolean; + user: string; +}): { + errors: BulkErrorSchema[]; + listItemsToDelete: Array<[string, NamespaceType]>; + listsToCreate: Array>; + listsToUpdate: Array>; +} => { + const results: { + errors: BulkErrorSchema[]; + listItemsToDelete: Array<[string, NamespaceType]>; + listsToCreate: Array>; + listsToUpdate: Array>; + } = { + errors: [], + listItemsToDelete: [], + listsToCreate: [], + listsToUpdate: [], + }; + + for (const chunk of lists) { + const { + description, + meta, + list_id: listId, + name, + namespace_type: namespaceType, + tags, + type, + version, + } = chunk; + const dateNow = new Date().toISOString(); + const savedObjectType = getSavedObjectType({ namespaceType }); + + if (existingLists[listId] == null) { + results.listsToCreate = [ + ...results.listsToCreate, + { + attributes: { + comments: undefined, + created_at: dateNow, + created_by: user, + description, + entries: undefined, + immutable: false, + item_id: undefined, + list_id: listId, + list_type: 'list', + meta, + name, + os_types: [], + tags, + tie_breaker_id: uuid.v4(), + type, + updated_by: user, + version, + }, + type: savedObjectType, + }, + ]; + } else if (existingLists[listId] != null && isOverwrite) { + results.listItemsToDelete = [...results.listItemsToDelete, [listId, namespaceType]]; + results.listsToUpdate = [ + ...results.listsToUpdate, + { + attributes: { + description, + meta, + name, + tags, + type, + updated_by: user, + }, + id: existingLists[listId].id, + type: savedObjectType, + }, + ]; + } else if (existingLists[listId] != null) { + results.errors = [ + ...results.errors, + { + error: { + message: `Found that list_id: "${listId}" already exists. Import of list_id: "${listId}" skipped.`, + status_code: 409, + }, + list_id: listId, + }, + ]; + } + } + return results; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_by_namespace.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_by_namespace.test.ts new file mode 100644 index 0000000000000..86ee53bf6dd25 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_by_namespace.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getImportExceptionsListItemSchemaDecodedMock, + getImportExceptionsListSchemaDecodedMock, +} from '../../../../../common/schemas/request/import_exceptions_schema.mock'; + +import { + sortItemsImportsByNamespace, + sortListsImportsByNamespace, +} from './sort_import_by_namespace'; + +describe('sort_import_by_namespace', () => { + describe('sortListsImportsByNamespace', () => { + it('returns empty arrays if no lists to sort', () => { + const result = sortListsImportsByNamespace([]); + + expect(result).toEqual([[], []]); + }); + + it('sorts lists by namespace', () => { + const result = sortListsImportsByNamespace([ + { ...getImportExceptionsListSchemaDecodedMock('list-id-1'), namespace_type: 'single' }, + { ...getImportExceptionsListSchemaDecodedMock('list-id-2'), namespace_type: 'agnostic' }, + { ...getImportExceptionsListSchemaDecodedMock('list-id-3'), namespace_type: 'single' }, + { ...getImportExceptionsListSchemaDecodedMock('list-id-4'), namespace_type: 'single' }, + ]); + + expect(result).toEqual([ + [{ ...getImportExceptionsListSchemaDecodedMock('list-id-2'), namespace_type: 'agnostic' }], + [ + { ...getImportExceptionsListSchemaDecodedMock('list-id-1'), namespace_type: 'single' }, + { ...getImportExceptionsListSchemaDecodedMock('list-id-3'), namespace_type: 'single' }, + { ...getImportExceptionsListSchemaDecodedMock('list-id-4'), namespace_type: 'single' }, + ], + ]); + }); + }); + + describe('sortItemsImportsByNamespace', () => { + it('returns empty arrays if no items to sort', () => { + const result = sortItemsImportsByNamespace([]); + + expect(result).toEqual([[], []]); + }); + + it('sorts lists by namespace', () => { + const result = sortItemsImportsByNamespace([ + { ...getImportExceptionsListItemSchemaDecodedMock('item-id-1'), namespace_type: 'single' }, + { + ...getImportExceptionsListItemSchemaDecodedMock('item-id-2'), + namespace_type: 'agnostic', + }, + { ...getImportExceptionsListItemSchemaDecodedMock('item-id-3'), namespace_type: 'single' }, + { ...getImportExceptionsListItemSchemaDecodedMock('item-id-4'), namespace_type: 'single' }, + ]); + + expect(result).toEqual([ + [ + { + ...getImportExceptionsListItemSchemaDecodedMock('item-id-2'), + namespace_type: 'agnostic', + }, + ], + [ + { + ...getImportExceptionsListItemSchemaDecodedMock('item-id-1'), + namespace_type: 'single', + }, + { + ...getImportExceptionsListItemSchemaDecodedMock('item-id-3'), + namespace_type: 'single', + }, + { + ...getImportExceptionsListItemSchemaDecodedMock('item-id-4'), + namespace_type: 'single', + }, + ], + ]); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_by_namespace.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_by_namespace.ts new file mode 100644 index 0000000000000..c7f50059c63e4 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_by_namespace.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ImportExceptionListItemSchemaDecoded, + ImportExceptionListSchemaDecoded, +} from '@kbn/securitysolution-io-ts-list-types'; + +/** + * Helper to sort exception lists by namespace type + * @param exceptions {array} exception lists to sort + * @returns {array} tuple of agnostic and non agnostic lists + */ +export const sortListsImportsByNamespace = ( + exceptions: ImportExceptionListSchemaDecoded[] +): [ImportExceptionListSchemaDecoded[], ImportExceptionListSchemaDecoded[]] => { + return exceptions.reduce< + [ImportExceptionListSchemaDecoded[], ImportExceptionListSchemaDecoded[]] + >( + ([agnostic, single], uniqueList) => { + if (uniqueList.namespace_type === 'agnostic') { + return [[...agnostic, uniqueList], single]; + } else { + return [agnostic, [...single, uniqueList]]; + } + }, + [[], []] + ); +}; + +/** + * Helper to sort exception list items by namespace type + * @param exceptions {array} exception list items to sort + * @returns {array} tuple of agnostic and non agnostic items + */ +export const sortItemsImportsByNamespace = ( + exceptions: ImportExceptionListItemSchemaDecoded[] +): [ImportExceptionListItemSchemaDecoded[], ImportExceptionListItemSchemaDecoded[]] => { + return exceptions.reduce< + [ImportExceptionListItemSchemaDecoded[], ImportExceptionListItemSchemaDecoded[]] + >( + ([agnostic, single], uniqueList) => { + if (uniqueList.namespace_type === 'agnostic') { + return [[...agnostic, uniqueList], single]; + } else { + return [agnostic, [...single, uniqueList]]; + } + }, + [[], []] + ); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_responses.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_responses.test.ts new file mode 100644 index 0000000000000..52a7549e3518a --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_responses.test.ts @@ -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 { sortImportResponses } from './sort_import_responses'; + +describe('sort_import_responses', () => { + describe('sortImportResponses', () => { + it('returns defaults if empty array passed in', () => { + const result = sortImportResponses([]); + + expect(result).toEqual({ errors: [], success: true, success_count: 0 }); + }); + + it('returns success false if any errors exist', () => { + const result = sortImportResponses([ + { + error: { + message: 'error occurred', + status_code: 400, + }, + id: '123', + }, + ]); + + expect(result).toEqual({ + errors: [ + { + error: { + message: 'error occurred', + status_code: 400, + }, + id: '123', + }, + ], + success: false, + success_count: 0, + }); + }); + + it('returns success true if no errors exist', () => { + const result = sortImportResponses([ + { + id: '123', + status_code: 200, + }, + ]); + + expect(result).toEqual({ + errors: [], + success: true, + success_count: 1, + }); + }); + + it('reports successes even when error exists', () => { + const result = sortImportResponses([ + { + id: '123', + status_code: 200, + }, + { + error: { + message: 'error occurred', + status_code: 400, + }, + id: '123', + }, + ]); + + expect(result).toEqual({ + errors: [ + { + error: { + message: 'error occurred', + status_code: 400, + }, + id: '123', + }, + ], + success: false, + success_count: 1, + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_responses.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_responses.ts new file mode 100644 index 0000000000000..dbbe662434b39 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_import_responses.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 { has } from 'lodash/fp'; +import { BulkErrorSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { ImportResponse } from '../../import_exception_list_and_items'; + +import { isImportRegular } from './is_import_regular'; + +/** + * Helper to sort responses into success and error and report on + * final results + * @param responses {array} + * @returns {object} totals of successes and errors + */ +export const sortImportResponses = ( + responses: ImportResponse[] +): { + errors: BulkErrorSchema[]; + success: boolean; + success_count: number; +} => { + const errorsResp = responses.filter((resp) => has('error', resp)) as BulkErrorSchema[]; + const successes = responses.filter((resp) => { + if (isImportRegular(resp)) { + return resp.status_code === 200; + } else { + return false; + } + }); + + return { + errors: errorsResp, + success: errorsResp.length === 0, + success_count: successes.length, + }; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/index.test.ts similarity index 99% rename from x-pack/plugins/lists/server/services/exception_lists/utils.test.ts rename to x-pack/plugins/lists/server/services/exception_lists/utils/index.test.ts index 074fdf92e2ac0..890196b24b3ea 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/index.test.ts @@ -9,7 +9,7 @@ import sinon from 'sinon'; import moment from 'moment'; import uuid from 'uuid'; -import { transformCreateCommentsToComments, transformUpdateCommentsToComments } from './utils'; +import { transformCreateCommentsToComments, transformUpdateCommentsToComments } from '.'; jest.mock('uuid/v4'); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts similarity index 99% rename from x-pack/plugins/lists/server/services/exception_lists/utils.ts rename to x-pack/plugins/lists/server/services/exception_lists/utils/index.ts index 610f73d4c2e80..019c0381884cb 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts @@ -21,7 +21,7 @@ import { } from '@kbn/securitysolution-io-ts-list-types'; import { getExceptionListType } from '@kbn/securitysolution-list-utils'; -import { ExceptionListSoSchema } from '../../schemas/saved_objects'; +import { ExceptionListSoSchema } from '../../../schemas/saved_objects'; export const transformSavedObjectToExceptionList = ({ savedObject, diff --git a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts index f33e6bcbb1143..5ea6f7a8853d8 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts @@ -18,6 +18,7 @@ import { LIST_INDEX, LIST_ITEM_INDEX, MAX_IMPORT_PAYLOAD_BYTES, + MAX_IMPORT_SIZE, } from '../../../common/constants.mock'; import { ListClient } from './list_client'; @@ -70,6 +71,7 @@ export const getListClientMock = (): ListClient => { importTimeout: IMPORT_TIMEOUT, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, + maxExceptionsImportSize: MAX_IMPORT_SIZE, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES, }, esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, diff --git a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx index 8f7471f255a5d..c6d594617c448 100644 --- a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx @@ -7,15 +7,13 @@ import type { Map as MbMap, Layer as MbLayer, Style as MbStyle } from '@kbn/mapbox-gl'; import _ from 'lodash'; +// @ts-expect-error +import { RGBAImage } from './image_utils'; import { AbstractLayer } from '../layer'; import { SOURCE_DATA_REQUEST_ID, LAYER_TYPE, LAYER_STYLE_TYPE } from '../../../../common/constants'; import { LayerDescriptor } from '../../../../common/descriptor_types'; import { DataRequest } from '../../util/data_request'; import { isRetina } from '../../../util'; -import { - addSpriteSheetToMapFromImageData, - loadSpriteSheetImageData, -} from '../../../connected_components/mb_map/utils'; import { DataRequestContext } from '../../../actions'; import { EMSTMSSource } from '../../sources/ems_tms_source'; import { TileStyle } from '../../styles/tile/tile_style'; @@ -118,7 +116,7 @@ export class EmsVectorTileLayer extends AbstractLayer { startLoading(SOURCE_DATA_REQUEST_ID, requestToken, nextMeta); const styleAndSprites = await this.getSource().getVectorStyleSheetAndSpriteMeta(isRetina()); const spriteSheetImageData = styleAndSprites.spriteMeta - ? await loadSpriteSheetImageData(styleAndSprites.spriteMeta.png) + ? await this._loadSpriteSheetImageData(styleAndSprites.spriteMeta.png) : undefined; const data = { ...styleAndSprites, @@ -210,6 +208,60 @@ export class EmsVectorTileLayer extends AbstractLayer { }); } + _getImageData(img: HTMLImageElement) { + const canvas = window.document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('failed to create canvas 2d context'); + } + canvas.width = img.width; + canvas.height = img.height; + context.drawImage(img, 0, 0, img.width, img.height); + return context.getImageData(0, 0, img.width, img.height); + } + + _isCrossOriginUrl(url: string) { + const a = window.document.createElement('a'); + a.href = url; + return ( + a.protocol !== window.document.location.protocol || + a.host !== window.document.location.host || + a.port !== window.document.location.port + ); + } + + _loadSpriteSheetImageData(imgUrl: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + if (this._isCrossOriginUrl(imgUrl)) { + image.crossOrigin = 'Anonymous'; + } + image.onload = (event) => { + resolve(this._getImageData(image)); + }; + image.onerror = (e) => { + reject(e); + }; + image.src = imgUrl; + }); + } + + _addSpriteSheetToMapFromImageData(json: EmsSpriteSheet, imgData: ImageData, mbMap: MbMap) { + for (const imageId in json) { + if (!(json.hasOwnProperty(imageId) && !mbMap.hasImage(imageId))) { + continue; + } + const { width, height, x, y, sdf, pixelRatio } = json[imageId]; + if (typeof width !== 'number' || typeof height !== 'number') { + continue; + } + + const data = new RGBAImage({ width, height }); + RGBAImage.copy(imgData, data, { x, y }, { x: 0, y: 0 }, { width, height }); + mbMap.addImage(imageId, data, { pixelRatio, sdf }); + } + } + syncLayerWithMB(mbMap: MbMap) { const vectorStyle = this._getVectorStyle(); if (!vectorStyle) { @@ -252,7 +304,7 @@ export class EmsVectorTileLayer extends AbstractLayer { if (!imageData) { return; } - addSpriteSheetToMapFromImageData(newJson, imageData, mbMap); + this._addSpriteSheetToMapFromImageData(newJson, imageData, mbMap); // sync layers const layers = vectorStyle.layers ? vectorStyle.layers : []; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/image_utils.js b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/image_utils.js similarity index 98% rename from x-pack/plugins/maps/public/connected_components/mb_map/image_utils.js rename to x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/image_utils.js index 3b19b474d699b..b907bea3cbad7 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/image_utils.js +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/image_utils.js @@ -8,7 +8,7 @@ /* @notice * This product includes code that is adapted from mapbox-gl-js, which is * available under a "BSD-3-Clause" license. - * https://github.com/mapbox/mapbox-gl-js/blob/master/src/util/image.js + * https://github.com/mapbox/mapbox-gl-js/blob/v1.13.2/src/util/image.js * * Copyright (c) 2016, Mapbox * diff --git a/x-pack/plugins/maps/public/classes/styles/vector/maki_icons.ts b/x-pack/plugins/maps/public/classes/styles/vector/maki_icons.ts new file mode 100644 index 0000000000000..3a5e78fc2ea73 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/maki_icons.ts @@ -0,0 +1,729 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const MAKI_ICONS = { + aerialway: { + label: 'Aerialway', + svg: '\n\n \n', + }, + airfield: { + label: 'Airfield', + svg: '\n\n \n', + }, + airport: { + label: 'Airport', + svg: '\n\n \n', + }, + 'alcohol-shop': { + label: 'Alcohol shop', + svg: '\n\n \n', + }, + 'american-football': { + label: 'American football', + svg: '\n\n \n', + }, + 'amusement-park': { + label: 'Amusement park', + svg: '\n\n \n', + }, + aquarium: { + label: 'Aquarium', + svg: '\n\n \n', + }, + 'arrow-es': { + label: 'Arrow', + svg: '\n\n \n', + }, + 'art-gallery': { + label: 'Art gallery', + svg: '\n\n \n', + }, + attraction: { + label: 'Attraction', + svg: '\n\n \n', + }, + bakery: { + label: 'Bakery', + svg: '\n\n \n \n', + }, + bank: { + label: 'Bank', + svg: '\n\n \n', + }, + bar: { + label: 'Bar', + svg: '\n\n \n', + }, + barrier: { + label: 'Barrier', + svg: '\n\n \n', + }, + baseball: { + label: 'Baseball', + svg: '\n\n \n', + }, + basketball: { + label: 'Basketball', + svg: '\n\n \n', + }, + bbq: { + label: 'BBQ', + svg: '\n\n \n', + }, + beach: { + label: 'Beach', + svg: '\n\n \n', + }, + beer: { + label: 'Beer', + svg: '\n\n \n', + }, + bicycle: { + label: 'Bicycle', + svg: '\n\n \n', + }, + 'bicycle-share': { + label: 'Bicycle share', + svg: '\n\n \n', + }, + 'blood-bank': { + label: 'Blood bank', + svg: '\n\n \n', + }, + 'boat-es': { + label: 'Boat', + svg: '\n\n \n', + }, + 'bowling-alley': { + label: 'Bowling alley', + svg: '\n\n \n', + }, + bridge: { + label: 'Bridge', + svg: '\n\n \n', + }, + building: { + label: 'Building', + svg: '\n\n \n', + }, + 'building-alt1': { + label: 'Building 2', + svg: '\n\n \n', + }, + bus: { + label: 'Bus', + svg: '\n\n \n', + }, + cafe: { + label: 'Cafe', + svg: '\n\n \n', + }, + campsite: { + label: 'Campsite', + svg: '\n\n \n', + }, + car: { + label: 'Car', + svg: '\n\n \n', + }, + 'car-top-es': { + label: 'Car 2', + svg: '\n\n \n', + }, + 'car-rental': { + label: 'Car rental', + svg: '\n\n \n \n \n \n', + }, + 'car-repair': { + label: 'Car repair', + svg: '\n\n \n \n \n \n', + }, + casino: { + label: 'Casino', + svg: '\n\n \n', + }, + castle: { + label: 'Castle', + svg: '\n\n \n', + }, + cemetery: { + label: 'Cemetery', + svg: '\n\n \n', + }, + 'charging-station': { + label: 'Charging station', + svg: '\n\n \n', + }, + cinema: { + label: 'Cinema', + svg: '\n\n \n', + }, + circle: { + label: 'Circle', + svg: '\n\n \n', + }, + 'circle-stroked': { + label: 'Circle stroked', + svg: '\n\n \n', + }, + city: { + label: 'City', + svg: '\n\n \n', + }, + 'clothing-store': { + label: 'Clothing store', + svg: '\n\n \n', + }, + college: { + label: 'College', + svg: '\n\n \n', + }, + commercial: { + label: 'Commercial', + svg: '\n\n \n', + }, + 'communications-tower': { + label: 'Communications tower', + svg: '\n\n \n \n \n', + }, + confectionery: { + label: 'Confectionery', + svg: '\n\n \n \n \n', + }, + convenience: { + label: 'Convenience', + svg: '\n\n \n \n \n', + }, + cricket: { + label: 'Cricket', + svg: '\n\n \n', + }, + cross: { + label: 'Cross', + svg: '\n\n \n', + }, + dam: { + label: 'Dam', + svg: '\n\n \n', + }, + danger: { + label: 'Danger', + svg: '\n\n \n', + }, + defibrillator: { + label: 'Defibrillator', + svg: '\n\n \n', + }, + dentist: { + label: 'Dentist', + svg: '\n\n \n', + }, + doctor: { + label: 'Doctor', + svg: '\n\n \n', + }, + 'dog-park': { + label: 'Dog park', + svg: '\n\n \n \n \n', + }, + 'drinking-water': { + label: 'Drinking water', + svg: '\n\n \n \n', + }, + embassy: { + label: 'Embassy', + svg: '\n\n \n', + }, + 'emergency-phone': { + label: 'Emergency phone', + svg: '\n\n \n', + }, + entrance: { + label: 'Entrance', + svg: '\n\n \n \n', + }, + 'entrance-alt1': { + label: 'Entrance 2', + svg: '\n\n \n', + }, + farm: { + label: 'Farm', + svg: '\n\n \n', + }, + 'fast-food': { + label: 'Fast food', + svg: '\n\n \n', + }, + fence: { + label: 'Fence', + svg: '\n\n \n', + }, + ferry: { + label: 'Ferry', + svg: '\n\n \n', + }, + 'fire-station': { + label: 'Fire station', + svg: '\n\n \n', + }, + 'fitness-centre': { + label: 'Fitness centre', + svg: '\n\n \n', + }, + florist: { + label: 'Florist', + svg: '\n\n \n', + }, + fuel: { + label: 'Fuel', + svg: '\n\n \n', + }, + furniture: { + label: 'Furniture', + svg: '\n\n \n \n \n \n', + }, + gaming: { + label: 'Gaming', + svg: '\n\n \n', + }, + garden: { + label: 'Garden', + svg: '\n\n \n', + }, + 'garden-centre': { + label: 'Garden centre', + svg: '\n\n \n', + }, + gift: { + label: 'Gift', + svg: '\n\n \n', + }, + globe: { + label: 'Globe', + svg: '\n\n \n \n \n \n \n \n \n \n', + }, + golf: { + label: 'Golf', + svg: '\n\n \n', + }, + grocery: { + label: 'Grocery', + svg: '\n\n \n \n \n', + }, + hairdresser: { + label: 'Hairdresser', + svg: '\n\n \n', + }, + harbor: { + label: 'Harbor', + svg: '\n\n \n', + }, + hardware: { + label: 'Hardware', + svg: '\n\n \n', + }, + heart: { + label: 'Heart', + svg: '\n\n \n', + }, + heliport: { + label: 'Heliport', + svg: '\n\n \n', + }, + home: { + label: 'Home', + svg: '\n\n \n', + }, + 'horse-riding': { + label: 'Horse riding', + svg: '\n\n \n', + }, + hospital: { + label: 'Hospital', + svg: '\n\n \n', + }, + 'ice-cream': { + label: 'Ice cream', + svg: '\n\n \n \n', + }, + industry: { + label: 'Industry', + svg: '\n\n \n', + }, + information: { + label: 'Information', + svg: '\n\n \n', + }, + 'jewelry-store': { + label: 'Jewelry store', + svg: '\n\n \n', + }, + karaoke: { + label: 'Karaoke', + svg: '\n\n \n \n \n \n', + }, + landmark: { + label: 'Landmark', + svg: '\n\n \n', + }, + landuse: { + label: 'Landuse', + svg: '\n\n \n', + }, + laundry: { + label: 'Laundry', + svg: '\n\n \n', + }, + library: { + label: 'Library', + svg: '\n\n \n', + }, + lighthouse: { + label: 'Lighthouse', + svg: '\n\n \n', + }, + lodging: { + label: 'Lodging', + svg: '\n\n \n', + }, + logging: { + label: 'Logging', + svg: '\n\n \n', + }, + marker: { + label: 'Marker', + svg: '\n\n \n', + }, + 'marker-stroked': { + label: 'Marker stroked', + svg: '\n\n \n', + }, + 'mobile-phone': { + label: 'Mobile phone', + svg: '\n\n \n', + }, + monument: { + label: 'Monument', + svg: '\n\n \n', + }, + mountain: { + label: 'Mountain', + svg: '\n\n \n', + }, + museum: { + label: 'Museum', + svg: '\n\n \n', + }, + music: { + label: 'Music', + svg: '\n\n \n', + }, + natural: { + label: 'Natural', + svg: '\n\n \n', + }, + 'oil-rig-es': { + label: 'Oil rig', + svg: '\n\n \n', + }, + optician: { + label: 'Optician', + svg: '\n\n \n', + }, + paint: { + label: 'Paint', + svg: '\n\n \n', + }, + park: { + label: 'Park', + svg: '\n\n \n', + }, + 'park-alt1': { + label: 'Park 2', + svg: '\n\n \n', + }, + parking: { + label: 'Parking', + svg: '\n\n \n', + }, + 'parking-garage': { + label: 'Parking garage', + svg: '\n\n \n', + }, + pharmacy: { + label: 'Pharmacy', + svg: '\n\n \n', + }, + 'picnic-site': { + label: 'Picnic site', + svg: '\n\n \n', + }, + pitch: { + label: 'Pitch', + svg: '\n\n \n', + }, + 'place-of-worship': { + label: 'Place of worship', + svg: '\n\n \n', + }, + playground: { + label: 'Playground', + svg: '\n\n \n', + }, + police: { + label: 'Police', + svg: '\n\n \n', + }, + post: { + label: 'Post', + svg: '\n\n \n', + }, + prison: { + label: 'Prison', + svg: '\n\n \n', + }, + rail: { + label: 'Rail', + svg: '\n\n \n', + }, + 'rail-light': { + label: 'Rail light', + svg: '\n\n \n', + }, + 'rail-metro': { + label: 'Rail metro', + svg: '\n\n \n', + }, + 'ranger-station': { + label: 'Ranger station', + svg: '\n\n \n', + }, + recycling: { + label: 'Recycling', + svg: '\n\n \n', + }, + 'religious-buddhist': { + label: 'Religious buddhist', + svg: '\n\n \n', + }, + 'religious-christian': { + label: 'Religious christian', + svg: '\n\n \n', + }, + 'religious-jewish': { + label: 'Religious jewish', + svg: '\n\n \n', + }, + 'religious-muslim': { + label: 'Religious muslim', + svg: '\n\n \n', + }, + 'residential-community': { + label: 'Residential community', + svg: '\n\n \n', + }, + restaurant: { + label: 'Restaurant', + svg: '\n\n \n', + }, + 'restaurant-noodle': { + label: 'Restaurant noodle', + svg: '\n\n \n \n \n', + }, + 'restaurant-pizza': { + label: 'Restaurant pizza', + svg: '\n\n \n \n \n', + }, + 'restaurant-seafood': { + label: 'Restaurant seafood', + svg: '\n\n \n \n \n', + }, + roadblock: { + label: 'Roadblock', + svg: '\n\n \n', + }, + rocket: { + label: 'Rocket', + svg: '\n\n \n', + }, + school: { + label: 'School', + svg: '\n\n \n', + }, + scooter: { + label: 'Scooter', + svg: '\n\n \n', + }, + shelter: { + label: 'Shelter', + svg: '\n\n \n', + }, + shoe: { + label: 'Shoe', + svg: '\n\n \n \n \n \n', + }, + shop: { + label: 'Shop', + svg: '\n\n \n', + }, + skateboard: { + label: 'Skateboard', + svg: '\n\n \n', + }, + skiing: { + label: 'Skiing', + svg: '\n\n \n', + }, + slaughterhouse: { + label: 'Slaughterhouse', + svg: '\n\n \n', + }, + slipway: { + label: 'Slipway', + svg: '\n\n \n \n \n', + }, + snowmobile: { + label: 'Snowmobile', + svg: '\n\n \n', + }, + soccer: { + label: 'Soccer', + svg: '\n\n \n', + }, + square: { + label: 'Square', + svg: '\n\n \n', + }, + 'square-stroked': { + label: 'Square stroked', + svg: '\n\n \n', + }, + stadium: { + label: 'Stadium', + svg: '\n\n \n', + }, + star: { + label: 'Star', + svg: '\n\n \n', + }, + 'star-stroked': { + label: 'Star stroked', + svg: '\n\n \n', + }, + suitcase: { + label: 'Suitcase', + svg: '\n\n \n', + }, + sushi: { + label: 'Sushi', + svg: '\n\n \n', + }, + swimming: { + label: 'Swimming', + svg: '\n\n \n', + }, + 'table-tennis': { + label: 'Table tennis', + svg: '\n\n \n', + }, + teahouse: { + label: 'Teahouse', + svg: '\n\n \n', + }, + telephone: { + label: 'Telephone', + svg: '\n\n \n', + }, + tennis: { + label: 'Tennis', + svg: '\n\n \n', + }, + theatre: { + label: 'Theatre', + svg: '\n\n \n', + }, + toilet: { + label: 'Toilet', + svg: '\n\n \n', + }, + town: { + label: 'Town', + svg: '\n\n \n', + }, + 'town-hall': { + label: 'Town hall', + svg: '\n\n \n', + }, + triangle: { + label: 'Triangle', + svg: '\n\n \n', + }, + 'triangle-stroked': { + label: 'Triangle stroked', + svg: '\n\n \n', + }, + veterinary: { + label: 'Veterinary', + svg: '\n\n \n \n \n \n \n', + }, + viewpoint: { + label: 'Viewpoint', + svg: '\n\n \n', + }, + village: { + label: 'Village', + svg: '\n\n \n', + }, + volcano: { + label: 'Volcano', + svg: '\n\n \n', + }, + volleyball: { + label: 'Volleyball', + svg: '\n\n \n', + }, + warehouse: { + label: 'Warehouse', + svg: '\n\n \n', + }, + 'waste-basket': { + label: 'Waste basket', + svg: '\n\n \n', + }, + watch: { + label: 'Watch', + svg: '\n\n \n \n', + }, + water: { + label: 'Water', + svg: '\n\n \n', + }, + waterfall: { + label: 'Waterfall', + svg: '\n\n \n', + }, + watermill: { + label: 'Watermill', + svg: '\n\n \n', + }, + wetland: { + label: 'Wetland', + svg: '\n\n \n', + }, + wheelchair: { + label: 'Wheelchair', + svg: '\n\n \n', + }, + windmill: { + label: 'Windmill', + svg: '\n\n \n', + }, + zoo: { + label: 'Zoo', + svg: '\n\n \n', + }, +}; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.test.tsx index 08ad93c5b8cb7..b3e5293d6860f 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.test.tsx @@ -73,14 +73,14 @@ describe('get mapbox icon-image expression (via internal _getMbIconImageExpressi const iconStyle = makeProperty({ iconPaletteId: 'filledShapes', }); - expect(iconStyle._getMbIconImageExpression(15)).toEqual([ + expect(iconStyle._getMbIconImageExpression()).toEqual([ 'match', ['to-string', ['get', 'foobar']], 'US', - 'circle-15', + 'circle', 'CN', - 'marker-15', - 'square-15', + 'marker', + 'square', ]); }); @@ -92,12 +92,12 @@ describe('get mapbox icon-image expression (via internal _getMbIconImageExpressi { stop: 'MX', icon: 'marker' }, ], }); - expect(iconStyle._getMbIconImageExpression(15)).toEqual([ + expect(iconStyle._getMbIconImageExpression()).toEqual([ 'match', ['to-string', ['get', 'foobar']], 'MX', - 'marker-15', - 'circle-15', + 'marker', + 'circle', ]); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx index b5d5e90efa45f..77510f9c82d0b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx @@ -10,8 +10,11 @@ import React from 'react'; import { EuiTextColor } from '@elastic/eui'; import type { Map as MbMap } from '@kbn/mapbox-gl'; import { DynamicStyleProperty } from './dynamic_style_property'; -// @ts-expect-error -import { getIconPalette, getMakiIconId, getMakiSymbolAnchor } from '../symbol_utils'; +import { + getIconPalette, + getMakiSymbolAnchor, + // @ts-expect-error +} from '../symbol_utils'; import { BreakedLegend } from '../components/legend/breaked_legend'; import { getOtherCategoryLabel, assignCategoriesToPalette } from '../style_util'; import { LegendProps } from './style_property'; @@ -31,13 +34,9 @@ export class DynamicIconProperty extends DynamicStyleProperty { mbStops.push(`${stop}`); - mbStops.push(getMakiIconId(style, iconPixelSize)); + mbStops.push(style); }); if (fallbackSymbolId) { - mbStops.push(getMakiIconId(fallbackSymbolId, iconPixelSize)); // last item is fallback style for anything that does not match provided stops + mbStops.push(fallbackSymbolId); // last item is fallback style for anything that does not match provided stops } return ['match', ['to-string', ['get', this.getMbFieldName()]], ...mbStops]; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.tsx index e76e9e936faec..5ea99e64e8626 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.tsx @@ -11,9 +11,7 @@ import { DynamicStyleProperty } from './dynamic_style_property'; import { OrdinalLegend } from '../components/legend/ordinal_legend'; import { makeMbClampedNumberExpression } from '../style_util'; import { - HALF_LARGE_MAKI_ICON_SIZE, - LARGE_MAKI_ICON_SIZE, - SMALL_MAKI_ICON_SIZE, + HALF_MAKI_ICON_SIZE, // @ts-expect-error } from '../symbol_utils'; import { FieldFormatter, MB_LOOKUP_FUNCTION, VECTOR_STYLES } from '../../../../../common/constants'; @@ -55,16 +53,9 @@ export class DynamicSizeProperty extends DynamicStyleProperty= HALF_LARGE_MAKI_ICON_SIZE - ? LARGE_MAKI_ICON_SIZE - : SMALL_MAKI_ICON_SIZE; - } - syncIconSizeWithMb(symbolLayerId: string, mbMap: MbMap) { const rangeFieldMeta = this.getRangeFieldMeta(); if (this._isSizeDynamicConfigComplete() && rangeFieldMeta) { - const halfIconPixels = this.getIconPixelSize() / 2; const targetName = this.getMbFieldName(); // Using property state instead of feature-state because layout properties do not support feature-state mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ @@ -78,9 +69,9 @@ export class DynamicSizeProperty extends DynamicStyleProperty { - syncIconWithMb(symbolLayerId: string, mbMap: MbMap, iconPixelSize: number) { + syncIconWithMb(symbolLayerId: string, mbMap: MbMap) { const symbolId = this._options.value; mbMap.setLayoutProperty(symbolLayerId, 'icon-anchor', getMakiSymbolAnchor(symbolId)); - mbMap.setLayoutProperty(symbolLayerId, 'icon-image', getMakiIconId(symbolId, iconPixelSize)); + mbMap.setLayoutProperty(symbolLayerId, 'icon-image', symbolId); } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/static_size_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/static_size_property.ts index de71d07aa7167..771e0f8f33a0c 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/static_size_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/static_size_property.ts @@ -9,9 +9,7 @@ import type { Map as MbMap } from '@kbn/mapbox-gl'; import { StaticStyleProperty } from './static_style_property'; import { VECTOR_STYLES } from '../../../../../common/constants'; import { - HALF_LARGE_MAKI_ICON_SIZE, - LARGE_MAKI_ICON_SIZE, - SMALL_MAKI_ICON_SIZE, + HALF_MAKI_ICON_SIZE, // @ts-expect-error } from '../symbol_utils'; import { SizeStaticOptions } from '../../../../../common/descriptor_types'; @@ -29,15 +27,8 @@ export class StaticSizeProperty extends StaticStyleProperty { mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', this._options.size); } - getIconPixelSize() { - return this._options.size >= HALF_LARGE_MAKI_ICON_SIZE - ? LARGE_MAKI_ICON_SIZE - : SMALL_MAKI_ICON_SIZE; - } - syncIconSizeWithMb(symbolLayerId: string, mbMap: MbMap) { - const halfIconPixels = this.getIconPixelSize() / 2; - mbMap.setLayoutProperty(symbolLayerId, 'icon-size', this._options.size / halfIconPixels); + mbMap.setLayoutProperty(symbolLayerId, 'icon-size', this._options.size / HALF_MAKI_ICON_SIZE); } syncCircleStrokeWidthWithMb(mbLayerId: string, mbMap: MbMap, hasNoRadius: boolean) { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js index 30cc93d65722b..07ac77dc0cb78 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js @@ -6,46 +6,76 @@ */ import React from 'react'; -import maki from '@elastic/maki'; import xml2js from 'xml2js'; +import { Canvg } from 'canvg'; +import calcSDF from 'bitmap-sdf'; import { parseXmlString } from '../../../../common/parse_xml_string'; import { SymbolIcon } from './components/legend/symbol_icon'; import { getIsDarkMode } from '../../../kibana_services'; +import { MAKI_ICONS } from './maki_icons'; -export const LARGE_MAKI_ICON_SIZE = 15; -const LARGE_MAKI_ICON_SIZE_AS_STRING = LARGE_MAKI_ICON_SIZE.toString(); -export const SMALL_MAKI_ICON_SIZE = 11; -export const HALF_LARGE_MAKI_ICON_SIZE = Math.ceil(LARGE_MAKI_ICON_SIZE); +const MAKI_ICON_SIZE = 16; +export const HALF_MAKI_ICON_SIZE = MAKI_ICON_SIZE / 2; -export const SYMBOLS = {}; -maki.svgArray.forEach((svgString) => { - const ID_FRAG = 'id="'; - const index = svgString.indexOf(ID_FRAG); - if (index !== -1) { - const idStartIndex = index + ID_FRAG.length; - const idEndIndex = svgString.substring(idStartIndex).indexOf('"') + idStartIndex; - const fullSymbolId = svgString.substring(idStartIndex, idEndIndex); - const symbolId = fullSymbolId.substring(0, fullSymbolId.length - 3); // remove '-15' or '-11' from id - const symbolSize = fullSymbolId.substring(fullSymbolId.length - 2); // grab last 2 chars from id - // only show large icons, small/large icon selection will based on configured size style - if (symbolSize === LARGE_MAKI_ICON_SIZE_AS_STRING) { - SYMBOLS[symbolId] = svgString; - } - } -}); - -export const SYMBOL_OPTIONS = Object.keys(SYMBOLS).map((symbolId) => { +export const SYMBOL_OPTIONS = Object.keys(MAKI_ICONS).map((symbolId) => { return { value: symbolId, label: symbolId, }; }); +/** + * Converts a SVG icon to a monochrome image using a signed distance function. + * + * @param {string} svgString - SVG icon as string + * @param {number} [cutoff=0.25] - balance between SDF inside 1 and outside 0 of glyph + * @param {number} [radius=0.25] - size of SDF around the cutoff as percent of output icon size + * @return {ImageData} Monochrome image that can be added to a MapLibre map + */ +export async function createSdfIcon(svgString, cutoff = 0.25, radius = 0.25) { + const buffer = 3; + const size = MAKI_ICON_SIZE + buffer * 4; + const svgCanvas = document.createElement('canvas'); + svgCanvas.width = size; + svgCanvas.height = size; + const svgCtx = svgCanvas.getContext('2d'); + const v = Canvg.fromString(svgCtx, svgString, { + ignoreDimensions: true, + offsetX: buffer / 2, + offsetY: buffer / 2, + }); + v.resize(size - buffer, size - buffer); + await v.render(); + + const distances = calcSDF(svgCtx, { + channel: 3, + cutoff, + radius: radius * size, + }); + + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + + const imageData = ctx.createImageData(size, size); + for (let i = 0; i < size; i++) { + for (let j = 0; j < size; j++) { + imageData.data[j * size * 4 + i * 4 + 0] = 0; + imageData.data[j * size * 4 + i * 4 + 1] = 0; + imageData.data[j * size * 4 + i * 4 + 2] = 0; + imageData.data[j * size * 4 + i * 4 + 3] = distances[j * size + i] * 255; + } + } + return imageData; +} + export function getMakiSymbolSvg(symbolId) { - if (!SYMBOLS[symbolId]) { + const svg = MAKI_ICONS?.[symbolId]?.svg; + if (!svg) { throw new Error(`Unable to find symbol: ${symbolId}`); } - return SYMBOLS[symbolId]; + return svg; } export function getMakiSymbolAnchor(symbolId) { @@ -59,12 +89,6 @@ export function getMakiSymbolAnchor(symbolId) { } } -// Style descriptor stores symbolId, for example 'aircraft' -// Icons are registered in Mapbox with full maki ids, for example 'aircraft-11' -export function getMakiIconId(symbolId, iconPixelSize) { - return `${symbolId}-${iconPixelSize}`; -} - export function buildSrcUrl(svgString) { const domUrl = window.URL || window.webkitURL || window; const svg = new Blob([svgString], { type: 'image/svg+xml' }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index 5afd05366ab1d..a4ea62cb63970 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -899,11 +899,7 @@ export class VectorStyle implements IVectorStyle { mbMap.setPaintProperty(symbolLayerId, 'icon-opacity', alpha); mbMap.setLayoutProperty(symbolLayerId, 'icon-allow-overlap', true); - this._iconStyleProperty.syncIconWithMb( - symbolLayerId, - mbMap, - this._iconSizeStyleProperty.getIconPixelSize() - ); + this._iconStyleProperty.syncIconWithMb(symbolLayerId, mbMap); // icon-color is only supported on SDF icons. this._fillColorStyleProperty.syncIconColorWithMb(symbolLayerId, mbMap); this._lineColorStyleProperty.syncHaloBorderColorWithMb(symbolLayerId, mbMap); diff --git a/x-pack/plugins/maps/public/components/__snapshots__/geo_index_pattern_select.test.tsx.snap b/x-pack/plugins/maps/public/components/__snapshots__/geo_index_pattern_select.test.tsx.snap index 070000a2f6b98..fc10eceb45601 100644 --- a/x-pack/plugins/maps/public/components/__snapshots__/geo_index_pattern_select.test.tsx.snap +++ b/x-pack/plugins/maps/public/components/__snapshots__/geo_index_pattern_select.test.tsx.snap @@ -14,6 +14,7 @@ exports[`should render 1`] = ` labelType="label" > { placeholder={getDataViewSelectPlaceholder()} onNoIndexPatterns={this._onNoIndexPatterns} isClearable={false} + data-test-subj="mapGeoIndexPatternSelect" /> diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index c262eaa9d1527..7646b6033a2f5 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -7,10 +7,6 @@ import _ from 'lodash'; import React, { Component } from 'react'; -// @ts-expect-error -import { spritesheet } from '@elastic/maki'; -import sprites1 from '@elastic/maki/dist/sprite@1.png'; -import sprites2 from '@elastic/maki/dist/sprite@2.png'; import { Adapters } from 'src/plugins/inspector/public'; import { Filter } from 'src/plugins/data/public'; import { Action, ActionExecutionContext } from 'src/plugins/ui_actions/public'; @@ -33,20 +29,18 @@ import { Timeslice, } from '../../../common/descriptor_types'; import { DECIMAL_DEGREES_PRECISION, RawValue, ZOOM_PRECISION } from '../../../common/constants'; -import { getGlyphUrl, isRetina } from '../../util'; +import { getGlyphUrl } from '../../util'; import { syncLayerOrder } from './sort_layers'; -import { - addSpriteSheetToMapFromImageData, - getTileMetaFeatures, - loadSpriteSheetImageData, - removeOrphanedSourcesAndLayers, -} from './utils'; +import { getTileMetaFeatures, removeOrphanedSourcesAndLayers } from './utils'; import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property'; import { TileStatusTracker } from './tile_status_tracker'; import { DrawFeatureControl } from './draw_control/draw_feature_control'; import type { MapExtentState } from '../../reducers/map/types'; +// @ts-expect-error +import { createSdfIcon } from '../../classes/styles/vector/symbol_utils'; +import { MAKI_ICONS } from '../../classes/styles/vector/maki_icons'; export interface Props { isMapReady: boolean; @@ -290,11 +284,17 @@ export class MbMap extends Component { } async _loadMakiSprites(mbMap: MapboxMap) { - const spritesUrl = isRetina() ? sprites2 : sprites1; - const json = isRetina() ? spritesheet[2] : spritesheet[1]; - const spritesData = await loadSpriteSheetImageData(spritesUrl); if (this._isMounted) { - addSpriteSheetToMapFromImageData(json, spritesData, mbMap); + const pixelRatio = Math.floor(window.devicePixelRatio); + for (const [symbolId, { svg }] of Object.entries(MAKI_ICONS)) { + if (!mbMap.hasImage(symbolId)) { + const imageData = await createSdfIcon(svg, 0.25, 0.25); + mbMap.addImage(symbolId, imageData, { + pixelRatio, + sdf: true, + }); + } + } } } diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/utils.ts b/x-pack/plugins/maps/public/connected_components/mb_map/utils.ts index f5de99d04c01c..a79c1a1f71b76 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/utils.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/utils.ts @@ -7,11 +7,8 @@ import type { Map as MbMap } from '@kbn/mapbox-gl'; import { TileMetaFeature } from '../../../common/descriptor_types'; -// @ts-expect-error -import { RGBAImage } from './image_utils'; import { isGlDrawLayer } from './sort_layers'; import { ILayer } from '../../classes/layers/layer'; -import { EmsSpriteSheet } from '../../classes/layers/ems_vector_tile_layer/ems_vector_tile_layer'; import { ES_MVT_META_LAYER_NAME } from '../../classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer'; export function removeOrphanedSourcesAndLayers( @@ -64,64 +61,6 @@ export function removeOrphanedSourcesAndLayers( mbSourcesToRemove.forEach((mbSourceId) => mbMap.removeSource(mbSourceId)); } -function getImageData(img: HTMLImageElement) { - const canvas = window.document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) { - throw new Error('failed to create canvas 2d context'); - } - canvas.width = img.width; - canvas.height = img.height; - context.drawImage(img, 0, 0, img.width, img.height); - return context.getImageData(0, 0, img.width, img.height); -} - -function isCrossOriginUrl(url: string) { - const a = window.document.createElement('a'); - a.href = url; - return ( - a.protocol !== window.document.location.protocol || - a.host !== window.document.location.host || - a.port !== window.document.location.port - ); -} - -export async function loadSpriteSheetImageData(imgUrl: string): Promise { - return new Promise((resolve, reject) => { - const image = new Image(); - if (isCrossOriginUrl(imgUrl)) { - image.crossOrigin = 'Anonymous'; - } - image.onload = (event) => { - resolve(getImageData(image)); - }; - image.onerror = (e) => { - reject(e); - }; - image.src = imgUrl; - }); -} - -export function addSpriteSheetToMapFromImageData( - json: EmsSpriteSheet, - imgData: ImageData, - mbMap: MbMap -) { - for (const imageId in json) { - if (!(json.hasOwnProperty(imageId) && !mbMap.hasImage(imageId))) { - continue; - } - const { width, height, x, y, sdf, pixelRatio } = json[imageId]; - if (typeof width !== 'number' || typeof height !== 'number') { - continue; - } - - const data = new RGBAImage({ width, height }); - RGBAImage.copy(imgData, data, { x, y }, { x: 0, y: 0 }, { width, height }); - mbMap.addImage(imageId, data, { pixelRatio, sdf }); - } -} - export function getTileMetaFeatures(mbMap: MbMap, mbSourceId: string): TileMetaFeature[] { // querySourceFeatures can return duplicated features when features cross tile boundaries. // Tile meta will never have duplicated features since by there nature, tile meta is a feature contained within a single tile diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx index 3955db278777a..4b42bc482a702 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx @@ -53,7 +53,7 @@ export class AttributionControl extends Component { return; } - const uniqueAttributions = []; + const uniqueAttributions: Attribution[] = []; for (let i = 0; i < attributions.length; i++) { for (let j = 0; j < attributions[i].length; j++) { const testAttr = attributions[i][j]; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/__snapshots__/layer_control.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/__snapshots__/layer_control.test.tsx.snap index 047f0087c559f..7d0c67ff41797 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/__snapshots__/layer_control.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/__snapshots__/layer_control.test.tsx.snap @@ -89,6 +89,7 @@ exports[`LayerControl isLayerTOCOpen Should render expand button 1`] = ` aria-label="Expand layers panel" className="mapLayerControl__openLayerTOCButton" color="text" + data-test-subj="mapExpandLayerControlButton" iconType="menuLeft" onClick={[Function]} /> @@ -106,6 +107,7 @@ exports[`LayerControl isLayerTOCOpen Should render expand button with error icon aria-label="Expand layers panel" className="mapLayerControl__openLayerTOCButton" color="text" + data-test-subj="mapExpandLayerControlButton" iconType="alert" onClick={[Function]} /> @@ -123,6 +125,7 @@ exports[`LayerControl isLayerTOCOpen spinner icon Should not render expand butto aria-label="Expand layers panel" className="mapLayerControl__openLayerTOCButton" color="text" + data-test-subj="mapExpandLayerControlButton" iconType="menuLeft" onClick={[Function]} /> @@ -139,6 +142,7 @@ exports[`LayerControl isLayerTOCOpen spinner icon Should render expand button wi @@ -66,6 +67,7 @@ function renderExpandButton({ onClick={onClick} iconType={hasErrors ? 'alert' : 'menuLeft'} aria-label={expandLabel} + data-test-subj="mapExpandLayerControlButton" /> ); } diff --git a/x-pack/plugins/ml/common/types/results.ts b/x-pack/plugins/ml/common/types/results.ts index f840f9ad58c01..3e18d85ce86a6 100644 --- a/x-pack/plugins/ml/common/types/results.ts +++ b/x-pack/plugins/ml/common/types/results.ts @@ -11,10 +11,14 @@ import { LineAnnotationDatum, RectAnnotationDatum } from '@elastic/charts'; export interface GetStoppedPartitionResult { jobs: string[] | Record; } + +export interface MLRectAnnotationDatum extends RectAnnotationDatum { + header: number; +} export interface GetDatafeedResultsChartDataResult { bucketResults: number[][]; datafeedResults: number[][]; - annotationResultsRect: RectAnnotationDatum[]; + annotationResultsRect: MLRectAnnotationDatum[]; annotationResultsLine: LineAnnotationDatum[]; modelSnapshotResultsLine: LineAnnotationDatum[]; } diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 5334f420698ab..9b9ed3a93322b 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -16,6 +16,7 @@ import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { KibanaContextProvider, + KibanaThemeProvider, RedirectAppLinks, } from '../../../../../src/plugins/kibana_react/public'; import { setDependencyCache, clearCache } from './util/dependency_cache'; @@ -99,14 +100,16 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { - - - + + + + + @@ -128,6 +131,7 @@ export const renderApp = ( docLinks: coreStart.docLinks!, toastNotifications: coreStart.notifications.toasts, overlays: coreStart.overlays, + theme: coreStart.theme, recentlyAccessed: coreStart.chrome!.recentlyAccessed, basePath: coreStart.http.basePath, savedObjectsClient: coreStart.savedObjects.client, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index 58f53efa4f4eb..cfdd2de06e0db 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -85,6 +85,7 @@ const checkboxDisabledCheck = (item: FieldSelectionItem) => export const AnalysisFieldsTable: FC<{ dependentVariable?: string; includes: string[]; + isJobTypeWithDepVar: boolean; setFormState: React.Dispatch>; minimumFieldsRequiredMessage?: string; setMinimumFieldsRequiredMessage: React.Dispatch>; @@ -95,6 +96,7 @@ export const AnalysisFieldsTable: FC<{ ({ dependentVariable, includes, + isJobTypeWithDepVar, setFormState, minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage, @@ -120,7 +122,7 @@ export const AnalysisFieldsTable: FC<{ } else if (includes.length > 0) { setFormState({ includes: - dependentVariable && includes.includes(dependentVariable) + (dependentVariable && includes.includes(dependentVariable)) || !isJobTypeWithDepVar ? includes : [...includes, dependentVariable], }); @@ -234,6 +236,7 @@ export const AnalysisFieldsTable: FC<{ onTableChange={(selection: string[]) => { // dependent variable must always be in includes if ( + isJobTypeWithDepVar && dependentVariable !== undefined && dependentVariable !== '' && selection.length === 0 diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index 720dcd232d2f3..158410c690a66 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -670,6 +670,7 @@ export const ConfigurationStepForm: FC = ({ = (prop const { advancedEditorMessages, advancedEditorRawString, isJobCreated } = state; - const { - createIndexPattern, - destinationIndexPatternTitleExists, - jobId, - jobIdEmpty, - jobIdExists, - jobIdValid, - } = state.form; + const { jobId, jobIdEmpty, jobIdExists, jobIdValid } = state.form; const forceInput = useRef(null); const { toasts } = useNotifications(); - const { - services: { - application: { capabilities }, - }, - } = useMlKibana(); const onChange = (str: string) => { setAdvancedEditorRawString(str); }; - const canCreateDataView = useMemo( - () => - capabilities.savedObjectsManagement.edit === true || capabilities.indexPatterns.save === true, - [capabilities] - ); - const debouncedJobIdCheck = useMemo( () => debounce(async () => { @@ -217,47 +190,6 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop ))} - {!isJobCreated && ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.dataViewPermissionWarning', - { - defaultMessage: 'You need permission to create data views.', - } - )} - , - ] - : []), - ...(createIndexPattern && destinationIndexPatternTitleExists - ? [ - i18n.translate('xpack.ml.dataframe.analytics.create.dataViewExistsError', { - defaultMessage: 'A data view with this title already exists.', - }), - ] - : []), - ]} - > - setFormState({ createIndexPattern: !createIndexPattern })} - /> - - - )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx index 77c00a94227f0..19b1570d1cf63 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, useState } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import { EuiButton, EuiCheckbox, @@ -13,9 +13,11 @@ import { EuiFlexItem, EuiFormRow, EuiSpacer, + EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useMlKibana } from '../../../../../contexts/kibana'; import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; import { Messages } from '../shared'; import { ANALYTICS_STEPS } from '../../page'; @@ -26,14 +28,38 @@ interface Props extends CreateAnalyticsFormProps { } export const CreateStep: FC = ({ actions, state, step }) => { - const { createAnalyticsJob, startAnalyticsJob } = actions; + const { + services: { + application: { capabilities }, + }, + } = useMlKibana(); + + const canCreateDataView = useMemo( + () => + capabilities.savedObjectsManagement.edit === true || capabilities.indexPatterns.save === true, + [capabilities] + ); + + const { createAnalyticsJob, setFormState, startAnalyticsJob } = actions; const { isAdvancedEditorValidJson, isJobCreated, isJobStarted, isValid, requestMessages } = state; - const { jobId, jobType } = state.form; + const { + createIndexPattern, + destinationIndex, + destinationIndexPatternTitleExists, + jobId, + jobType, + } = state.form; - const [checked, setChecked] = useState(true); + const [startChecked, setStartChecked] = useState(true); const [creationTriggered, setCreationTriggered] = useState(false); const [showProgress, setShowProgress] = useState(false); + useEffect(() => { + if (canCreateDataView === false) { + setFormState({ createIndexPattern: false }); + } + }, [capabilities]); + if (step !== ANALYTICS_STEPS.CREATE) return null; const handleCreation = async () => { @@ -44,7 +70,7 @@ export const CreateStep: FC = ({ actions, state, step }) => { setCreationTriggered(false); } - if (checked && creationSuccess === true) { + if (startChecked && creationSuccess === true) { setShowProgress(true); startAnalyticsJob(); } @@ -53,32 +79,114 @@ export const CreateStep: FC = ({ actions, state, step }) => { return (
{!isJobCreated && !isJobStarted && ( - + - - setChecked(e.target.checked)} - /> - + + + + { + setStartChecked(e.target.checked); + if (e.target.checked === false) { + setFormState({ createIndexPattern: false }); + } + }} + /> + + + {startChecked ? ( + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.dataViewPermissionWarning', + { + defaultMessage: 'You need permission to create data views.', + } + )} + , + ] + : []), + ...(createIndexPattern && destinationIndexPatternTitleExists + ? [ + i18n.translate( + 'xpack.ml.dataframe.analytics.create.dataViewExistsError', + { + defaultMessage: + 'A data view with the title {title} already exists.', + values: { title: destinationIndex }, + } + ), + ] + : []), + ...(!createIndexPattern && !destinationIndexPatternTitleExists + ? [ + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.shouldCreateDataViewMessage', + { + defaultMessage: + 'You may not be able to view job results if a data view is not created for the destination index.', + } + )} + , + ] + : []), + ]} + > + setFormState({ createIndexPattern: !createIndexPattern })} + data-test-subj="mlAnalyticsCreateJobWizardCreateIndexPatternCheckbox" + /> + + + ) : null} + = ({ actions, state, step }) => { )} - {isJobCreated === true && ( + {isJobCreated === true ? ( - )} + ) : null}
); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step_footer/create_step_footer.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step_footer/create_step_footer.tsx index 3123a43594c93..f8016bccb1832 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step_footer/create_step_footer.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step_footer/create_step_footer.tsx @@ -54,6 +54,8 @@ export const CreateStepFooter: FC = ({ jobId, jobType, showProgress }) => }, []); useEffect(() => { + if (showProgress === false) return; + const interval = setInterval(async () => { try { const analyticsStats = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(jobId); @@ -129,11 +131,11 @@ export const CreateStepFooter: FC = ({ jobId, jobType, showProgress }) => - {jobFinished === true && ( + {jobFinished === true ? ( - )} + ) : null} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx index d3b4c27e16e78..6e702c8ab6d1d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx @@ -7,15 +7,7 @@ import React, { FC, Fragment, useRef, useEffect, useMemo, useState } from 'react'; import { debounce } from 'lodash'; -import { - EuiFieldText, - EuiFormRow, - EuiLink, - EuiSpacer, - EuiSwitch, - EuiText, - EuiTextArea, -} from '@elastic/eui'; +import { EuiFieldText, EuiFormRow, EuiLink, EuiSpacer, EuiSwitch, EuiTextArea } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useMlKibana } from '../../../../../contexts/kibana'; @@ -42,23 +34,17 @@ export const DetailsStepForm: FC = ({ setCurrentStep, }) => { const { - services: { - docLinks, - notifications, - application: { capabilities }, - }, + services: { docLinks, notifications }, } = useMlKibana(); const createIndexLink = docLinks.links.apis.createIndex; const { setFormState } = actions; const { form, cloneJob, hasSwitchedToEditor, isJobCreated } = state; const { - createIndexPattern, description, destinationIndex, destinationIndexNameEmpty, destinationIndexNameExists, destinationIndexNameValid, - destinationIndexPatternTitleExists, jobId, jobIdEmpty, jobIdExists, @@ -75,11 +61,6 @@ export const DetailsStepForm: FC = ({ (cloneJob !== undefined && resultsField === DEFAULT_RESULTS_FIELD) ); - const canCreateDataView = useMemo( - () => - capabilities.savedObjectsManagement.edit === true || capabilities.indexPatterns.save === true, - [capabilities] - ); const forceInput = useRef(null); const isStepInvalid = @@ -87,8 +68,7 @@ export const DetailsStepForm: FC = ({ jobIdExists === true || jobIdValid === false || destinationIndexNameEmpty === true || - destinationIndexNameValid === false || - (destinationIndexPatternTitleExists === true && createIndexPattern === true); + destinationIndexNameValid === false; const debouncedIndexCheck = debounce(async () => { try { @@ -158,12 +138,6 @@ export const DetailsStepForm: FC = ({ } }, [destIndexSameAsId, jobId]); - useEffect(() => { - if (canCreateDataView === false) { - setFormState({ createIndexPattern: false }); - } - }, [capabilities]); - return ( = ({ /> )} - - {i18n.translate('xpack.ml.dataframe.analytics.create.dataViewPermissionWarning', { - defaultMessage: 'You need permission to create data views.', - })} - , - ] - : []), - ...(createIndexPattern && destinationIndexPatternTitleExists - ? [ - i18n.translate('xpack.ml.dataframe.analytics.create.dataViewExistsError', { - defaultMessage: 'A data view with this title already exists.', - }), - ] - : []), - ...(!createIndexPattern - ? [ - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.shouldCreateDataViewMessage', - { - defaultMessage: - 'You may not be able to view job results if a data view is not created for the destination index.', - } - )} - , - ] - : []), - ]} - > - setFormState({ createIndexPattern: !createIndexPattern })} - data-test-subj="mlAnalyticsCreateJobWizardCreateIndexPatternSwitch" - /> - { const checkIndexPatternExists = async () => { try { - const dv = (await dataViews.find(indexName)).find(({ title }) => title === indexName); + const dv = (await dataViews.getIdsWithTitle()).find(({ title }) => title === indexName); if (dv !== undefined) { setIndexPatternExists(true); } else { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 88b0774e107e2..94a12f9ad5235 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -47,6 +47,49 @@ export interface CreateAnalyticsStepProps extends CreateAnalyticsFormProps { stepActivated?: boolean; } +async function checkIndexExists(destinationIndex: string) { + let resp; + let errorMessage; + try { + resp = await ml.checkIndicesExists({ indices: [destinationIndex] }); + } catch (e) { + errorMessage = extractErrorMessage(e); + } + return { resp, errorMessage }; +} + +async function retryIndexExistsCheck( + destinationIndex: string +): Promise<{ success: boolean; indexExists: boolean; errorMessage?: string }> { + let retryCount = 15; + + let resp = await checkIndexExists(destinationIndex); + let indexExists = resp.resp && resp.resp[destinationIndex] && resp.resp[destinationIndex].exists; + + while (retryCount > 1 && !indexExists) { + retryCount--; + await delay(1000); + resp = await checkIndexExists(destinationIndex); + indexExists = resp.resp && resp.resp[destinationIndex] && resp.resp[destinationIndex].exists; + } + + if (indexExists) { + return { success: true, indexExists: true }; + } + + return { + success: false, + indexExists: false, + ...(resp.errorMessage !== undefined ? { errorMessage: resp.errorMessage } : {}), + }; +} + +function delay(ms = 1000) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { const mlContext = useMlContext(); const [state, dispatch] = useReducer(reducer, getInitialState()); @@ -125,49 +168,88 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { const createKibanaIndexPattern = async () => { const dataViewName = destinationIndex; - - try { - await mlContext.dataViewsContract.createAndSave( - { - title: dataViewName, - }, - false, - true - ); - - addRequestMessage({ - message: i18n.translate( - 'xpack.ml.dataframe.analytics.create.createDataViewSuccessMessage', - { - defaultMessage: 'Kibana data view {dataViewName} created.', - values: { dataViewName }, + const exists = await retryIndexExistsCheck(destinationIndex); + if (exists?.success === true) { + // index exists - create data view + if (exists?.indexExists === true) { + try { + await mlContext.dataViewsContract.createAndSave( + { + title: dataViewName, + }, + false, + true + ); + addRequestMessage({ + message: i18n.translate( + 'xpack.ml.dataframe.analytics.create.createDataViewSuccessMessage', + { + defaultMessage: 'Kibana data view {dataViewName} created.', + values: { dataViewName }, + } + ), + }); + } catch (e) { + // handle data view creation error + if (e instanceof DuplicateDataViewError) { + addRequestMessage({ + error: i18n.translate( + 'xpack.ml.dataframe.analytics.create.duplicateDataViewErrorMessageError', + { + defaultMessage: 'The data view {dataViewName} already exists.', + values: { dataViewName }, + } + ), + message: i18n.translate( + 'xpack.ml.dataframe.analytics.create.duplicateDataViewErrorMessage', + { + defaultMessage: 'An error occurred creating the Kibana data view:', + } + ), + }); + } else { + addRequestMessage({ + error: extractErrorMessage(e), + message: i18n.translate( + 'xpack.ml.dataframe.analytics.create.createDataViewErrorMessage', + { + defaultMessage: 'An error occurred creating the Kibana data view:', + } + ), + }); } - ), - }); - } catch (e) { - if (e instanceof DuplicateDataViewError) { + } + } + } else { + // Ran out of retries or there was a problem checking index exists + if (exists?.errorMessage) { addRequestMessage({ error: i18n.translate( - 'xpack.ml.dataframe.analytics.create.duplicateDataViewErrorMessageError', + 'xpack.ml.dataframe.analytics.create.errorCheckingDestinationIndexDataFrameAnalyticsJob', { - defaultMessage: 'The data view {dataViewName} already exists.', - values: { dataViewName }, + defaultMessage: '{errorMessage}', + values: { errorMessage: exists.errorMessage }, } ), message: i18n.translate( - 'xpack.ml.dataframe.analytics.create.duplicateDataViewErrorMessage', + 'xpack.ml.dataframe.analytics.create.errorOccurredCheckingDestinationIndexDataFrameAnalyticsJob', { - defaultMessage: 'An error occurred creating the Kibana data view:', + defaultMessage: 'An error occurred checking destination index exists.', } ), }); } else { addRequestMessage({ - error: extractErrorMessage(e), + error: i18n.translate( + 'xpack.ml.dataframe.analytics.create.destinationIndexNotCreatedForDataFrameAnalyticsJob', + { + defaultMessage: 'Destination index has not yet been created.', + } + ), message: i18n.translate( - 'xpack.ml.dataframe.analytics.create.createDataViewErrorMessage', + 'xpack.ml.dataframe.analytics.create.unableToCreateDataViewForDataFrameAnalyticsJob', { - defaultMessage: 'An error occurred creating the Kibana data view:', + defaultMessage: 'Unable to create data view.', } ), }); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_chart_flyout/datafeed_chart_flyout.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_chart_flyout/datafeed_chart_flyout.tsx index 7c814b4b17baa..970b5ee8b1c89 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_chart_flyout/datafeed_chart_flyout.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_chart_flyout/datafeed_chart_flyout.tsx @@ -32,6 +32,7 @@ import { Axis, Chart, CurveType, + CustomAnnotationTooltip, LineAnnotation, LineSeries, LineAnnotationDatum, @@ -69,6 +70,14 @@ function setLineAnnotationHeader(lineDatum: LineAnnotationDatum) { return lineDatum; } +const customTooltip: CustomAnnotationTooltip = ({ details, datum }) => ( +
+ {/* @ts-ignore 'header does not exist on type RectAnnotationDatum' */} +

{dateFormatter(datum.header)}

+
{details}
+
+); + export const DatafeedChartFlyout: FC = ({ jobId, end, onClose }) => { const [data, setData] = useState<{ datafeedConfig: CombinedJobWithStats['datafeed_config'] | undefined; @@ -385,6 +394,7 @@ export const DatafeedChartFlyout: FC = ({ jobId, end, /> ) + toMountPoint( + wrapWithTheme( + , + theme.theme$ + ) + ) ); } } diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 3a969823088f1..083982e8fccd4 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -30,6 +30,7 @@ import type { ManagementAppMountParams } from '../../../../../../../../../src/pl import { checkGetManagementMlJobsResolver } from '../../../../capabilities/check_capabilities'; import { KibanaContextProvider, + KibanaThemeProvider, RedirectAppLinks, } from '../../../../../../../../../src/plugins/kibana_react/public'; @@ -139,6 +140,7 @@ export const JobsListPage: FC<{ const tabs = useTabs(isMlEnabledInSpace, spacesApi); const [currentTabId, setCurrentTabId] = useState('anomaly-detector'); const I18nContext = coreStart.i18n.Context; + const theme$ = coreStart.theme.theme$; const check = async () => { try { @@ -219,69 +221,71 @@ export const JobsListPage: FC<{ return ( - - - - - } - description={ - - } - rightSideItems={[docsLink]} - bottomBorder - /> + + + + + + } + description={ + + } + rightSideItems={[docsLink]} + bottomBorder + /> - + - - - - {spacesEnabled && ( - <> - setShowSyncFlyout(true)} - data-test-subj="mlStackMgmtSyncButton" - > - {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { - defaultMessage: 'Synchronize saved objects', - })} - - {showSyncFlyout && } - - - )} - - - - - - - - - {renderTabs()} - - - - + + + + {spacesEnabled && ( + <> + setShowSyncFlyout(true)} + data-test-subj="mlStackMgmtSyncButton" + > + {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { + defaultMessage: 'Synchronize saved objects', + })} + + {showSyncFlyout && } + + + )} + + + + + + + + + {renderTabs()} + + + + + ); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/force_stop_dialog.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/force_stop_dialog.tsx index 86120a4003e23..30e110317148b 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/force_stop_dialog.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/force_stop_dialog.tsx @@ -8,9 +8,9 @@ import React, { FC } from 'react'; import { EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { OverlayStart } from 'kibana/public'; +import type { OverlayStart, ThemeServiceStart } from 'kibana/public'; import type { ModelItem } from './models_list'; -import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint, wrapWithTheme } from '../../../../../../../src/plugins/kibana_react/public'; interface ForceStopModelConfirmDialogProps { model: ModelItem; @@ -64,22 +64,25 @@ export const ForceStopModelConfirmDialog: FC = }; export const getUserConfirmationProvider = - (overlays: OverlayStart) => async (forceStopModel: ModelItem) => { + (overlays: OverlayStart, theme: ThemeServiceStart) => async (forceStopModel: ModelItem) => { return new Promise(async (resolve, reject) => { try { const modalSession = overlays.openModal( toMountPoint( - { - modalSession.close(); - resolve(false); - }} - onConfirm={() => { - modalSession.close(); - resolve(true); - }} - /> + wrapWithTheme( + { + modalSession.close(); + resolve(false); + }} + onConfirm={() => { + modalSession.close(); + resolve(true); + }} + />, + theme.theme$ + ) ) ); } catch (e) { diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx index c80ff808aa539..75659a1e3567d 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx @@ -82,6 +82,7 @@ export const ModelsList: FC = () => { services: { application: { navigateToUrl, capabilities }, overlays, + theme, }, } = useMlKibana(); const urlLocator = useMlLocator()!; @@ -112,7 +113,7 @@ export const ModelsList: FC = () => { {} ); - const getUserConfirmation = useMemo(() => getUserConfirmationProvider(overlays), []); + const getUserConfirmation = useMemo(() => getUserConfirmationProvider(overlays, theme), []); const navigateToPath = useNavigateToPath(); diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index 7b6b75677dddd..93d7c069d873d 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -16,6 +16,7 @@ import type { DocLinksStart, ToastsStart, OverlayStart, + ThemeServiceStart, ChromeRecentlyAccessed, IBasePath, } from 'kibana/public'; @@ -34,6 +35,7 @@ export interface DependencyCache { docLinks: DocLinksStart | null; toastNotifications: ToastsStart | null; overlays: OverlayStart | null; + theme: ThemeServiceStart | null; recentlyAccessed: ChromeRecentlyAccessed | null; fieldFormats: DataPublicPluginStart['fieldFormats'] | null; autocomplete: DataPublicPluginStart['autocomplete'] | null; @@ -57,6 +59,7 @@ const cache: DependencyCache = { docLinks: null, toastNotifications: null, overlays: null, + theme: null, recentlyAccessed: null, fieldFormats: null, autocomplete: null, @@ -80,6 +83,7 @@ export function setDependencyCache(deps: Partial) { cache.docLinks = deps.docLinks || null; cache.toastNotifications = deps.toastNotifications || null; cache.overlays = deps.overlays || null; + cache.theme = deps.theme || null; cache.recentlyAccessed = deps.recentlyAccessed || null; cache.fieldFormats = deps.fieldFormats || null; cache.autocomplete = deps.autocomplete || null; @@ -128,6 +132,13 @@ export function getOverlays() { return cache.overlays; } +export function getTheme() { + if (cache.theme === null) { + throw new Error("theme hasn't been initialized"); + } + return cache.theme; +} + export function getUiSettings() { if (cache.config === null) { throw new Error("uiSettings hasn't been initialized"); diff --git a/x-pack/plugins/ml/public/application/util/index_utils.ts b/x-pack/plugins/ml/public/application/util/index_utils.ts index 5d35081dcb0fc..503c20dfc5bdb 100644 --- a/x-pack/plugins/ml/public/application/util/index_utils.ts +++ b/x-pack/plugins/ml/public/application/util/index_utils.ts @@ -48,11 +48,12 @@ export async function getDataViewIdFromName(name: string): Promise title === name); + if (!dataView) { return null; } - return dv.id ?? dv.title; + return dataView.id ?? dataView.title; } export function getDataViewById(id: string): Promise { diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx index 60b7c628229b9..ce0a270c35306 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx @@ -10,7 +10,10 @@ import ReactDOM from 'react-dom'; import { CoreStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { Subject } from 'rxjs'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../../../src/plugins/kibana_react/public'; import { Embeddable, IContainer } from '../../../../../../src/plugins/embeddable/public'; import { EmbeddableAnomalyChartsContainer } from './embeddable_anomaly_charts_container_lazy'; import type { JobId } from '../../../common/types/anomaly_detection_jobs'; @@ -96,22 +99,25 @@ export class AnomalyChartsEmbeddable extends Embeddable< this.node = node; const I18nContext = this.services[0].i18n.Context; + const theme$ = this.services[0].theme.theme$; ReactDOM.render( - - }> - - - + + + }> + + + + , node ); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx index 5090274ca7383..c4ac15ffdbe76 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { CoreStart } from 'kibana/public'; import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint, wrapWithTheme } from '../../../../../../src/plugins/kibana_react/public'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { getDefaultExplorerChartsPanelTitle } from './anomaly_charts_embeddable'; import { HttpService } from '../../application/services/http_service'; @@ -31,24 +31,28 @@ export async function resolveEmbeddableAnomalyChartsUserInput( const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); const influencers = anomalyDetectorService.extractInfluencers(jobs); influencers.push(VIEW_BY_JOB_LABEL); + const { theme$ } = coreStart.theme; const modalSession = overlays.openModal( toMountPoint( - { - modalSession.close(); - resolve({ - jobIds, - title: panelTitle, - maxSeriesToPlot, - }); - }} - onCancel={() => { - modalSession.close(); - reject(); - }} - /> + wrapWithTheme( + { + modalSession.close(); + resolve({ + jobIds, + title: panelTitle, + maxSeriesToPlot, + }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + />, + theme$ + ) ) ); } catch (error) { diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index 7f9e99f3a0c8e..e168029148006 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx @@ -10,7 +10,10 @@ import ReactDOM from 'react-dom'; import { CoreStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { Subject } from 'rxjs'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../../../src/plugins/kibana_react/public'; import { Embeddable, IContainer } from '../../../../../../src/plugins/embeddable/public'; import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container_lazy'; import type { JobId } from '../../../common/types/anomaly_detection_jobs'; @@ -58,22 +61,25 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< this.node = node; const I18nContext = this.services[0].i18n.Context; + const theme$ = this.services[0].theme.theme$; ReactDOM.render( - - }> - - - + + + }> + + + + , node ); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx index 5027eb6783a64..28cf197de5dfe 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { CoreStart } from 'kibana/public'; import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint, wrapWithTheme } from '../../../../../../src/plugins/kibana_react/public'; import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { getDefaultSwimlanePanelTitle } from './anomaly_swimlane_embeddable'; @@ -31,26 +31,30 @@ export async function resolveAnomalySwimlaneUserInput( const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); const influencers = anomalyDetectorService.extractInfluencers(jobs); influencers.push(VIEW_BY_JOB_LABEL); + const { theme$ } = coreStart.theme; const modalSession = overlays.openModal( toMountPoint( - { - modalSession.close(); - resolve({ - jobIds, - title: panelTitle, - swimlaneType, - viewBy, - }); - }} - onCancel={() => { - modalSession.close(); - reject(); - }} - /> + wrapWithTheme( + { + modalSession.close(); + resolve({ + jobIds, + title: panelTitle, + swimlaneType, + viewBy, + }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + />, + theme$ + ) ) ); } catch (error) { diff --git a/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx b/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx index fbceeb7f7cf79..bf7ea8eac3f50 100644 --- a/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx +++ b/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx @@ -13,6 +13,7 @@ import { getInitialGroupsMap } from '../../application/components/job_selector/j import { KibanaContextProvider, toMountPoint, + wrapWithTheme, } from '../../../../../../src/plugins/kibana_react/public'; import { getMlGlobalServices } from '../../application/app'; import { DashboardConstants } from '../../../../../../src/plugins/dashboard/public'; @@ -34,6 +35,7 @@ export async function resolveJobSelection( const { http, uiSettings, + theme, application: { currentAppId$ }, } = coreStart; @@ -70,18 +72,23 @@ export async function resolveJobSelection( const flyoutSession = coreStart.overlays.openFlyout( toMountPoint( - - - + wrapWithTheme( + + + , + theme.theme$ + ) ), { 'data-test-subj': 'mlFlyoutJobSelector', diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts index ac5602ddd830f..a34ac14d95767 100644 --- a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts @@ -108,7 +108,9 @@ export function alertingServiceProvider( try { const dataViewsService = await getDataViewsService(); - const dataView = (await dataViewsService.find(indexPattern, 1))[0]; + const dataViews = await dataViewsService.find(indexPattern); + const dataView = dataViews.find(({ title }) => title === indexPattern); + if (!dataView) return; return dataView.fieldFormatMap; diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.ts b/x-pack/plugins/ml/server/models/results_service/results_service.ts index 48eb5ad727b3c..ad31e8c7e89a4 100644 --- a/x-pack/plugins/ml/server/models/results_service/results_service.ts +++ b/x-pack/plugins/ml/server/models/results_service/results_service.ts @@ -780,6 +780,8 @@ export function resultsServiceProvider(mlClient: MlClient, client?: IScopedClust x1: endTimestamp, }, details: annotation.annotation, + // Added for custom RectAnnotation tooltip with formatted timestamp + header: timestamp, }); } }); diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index e1a839b21f7b0..29edb6106a993 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -118,6 +118,7 @@ "MlInfo", "MlEsSearch", "MlIndexExists", + "MlSpecificIndexExists", "JobAuditMessages", "GetJobAuditMessages", diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index 726d4d080ec19..c0fb32df0fd18 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -225,17 +225,16 @@ export function systemRoutes( try { const { indices } = request.body; - const options = { - index: indices, - fields: ['*'], - ignore_unavailable: true, - allow_no_indices: true, - }; - - const { body } = await client.asCurrentUser.fieldCaps(options); + const results = await Promise.all( + indices.map(async (index) => + client.asCurrentUser.indices.exists({ + index, + }) + ) + ); - const result = indices.reduce((acc, cur) => { - acc[cur] = { exists: body.indices.includes(cur) }; + const result = indices.reduce((acc, cur, i) => { + acc[cur] = { exists: results[i].body }; return acc; }, {} as Record); diff --git a/x-pack/plugins/monitoring/server/alerts/base_rule.ts b/x-pack/plugins/monitoring/server/alerts/base_rule.ts index 51b779083bf4b..f05077ec4bb00 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_rule.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_rule.ts @@ -8,7 +8,7 @@ import { Logger, ElasticsearchClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { - AlertType, + RuleType, AlertExecutorOptions, AlertInstance, RulesClient, @@ -80,7 +80,7 @@ export class BaseRule { this.scopedLogger = Globals.app.getLogger(ruleOptions.id); } - public getRuleType(): AlertType { + public getRuleType(): RuleType { const { id, name, actionVariables } = this.ruleOptions; return { id, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts index 974ffad7745d9..dccdefa457b1e 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts @@ -86,7 +86,12 @@ export function handleMbLastRecoveries(resp: ElasticsearchResponse, start: numbe return filtered; } -export async function getLastRecovery(req: LegacyRequest, esIndexPattern: string, size: number) { +export async function getLastRecovery( + req: LegacyRequest, + esIndexPattern: string, + mbIndexPattern: string, + size: number +) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getLastRecovery'); const start = req.payload.timeRange.min; @@ -105,7 +110,7 @@ export async function getLastRecovery(req: LegacyRequest, esIndexPattern: string }, }; const mbParams = { - index: esIndexPattern, + index: mbIndexPattern, size, ignore_unavailable: true, body: { diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js index a0fc524768eb9..ff2883df49ff8 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js @@ -40,6 +40,19 @@ export function esOverviewRoute(server) { const ccs = req.payload.ccs; const clusterUuid = req.params.clusterUuid; const esIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_ELASTICSEARCH, ccs); + const esLegacyIndexPattern = prefixIndexPattern( + config, + INDEX_PATTERN_ELASTICSEARCH, + ccs, + true + ); + const mbIndexPattern = prefixIndexPattern( + config, + config.get('monitoring.ui.metricbeat.index'), + ccs, + true + ); + const filebeatIndexPattern = prefixIndexPattern( config, config.get('monitoring.ui.logs.index'), @@ -54,7 +67,12 @@ export function esOverviewRoute(server) { const [clusterStats, metrics, shardActivity, logs] = await Promise.all([ getClusterStats(req, esIndexPattern, clusterUuid), getMetrics(req, esIndexPattern, metricSet), - getLastRecovery(req, esIndexPattern, config.get('monitoring.ui.max_bucket_size')), + getLastRecovery( + req, + esLegacyIndexPattern, + mbIndexPattern, + config.get('monitoring.ui.max_bucket_size') + ), getLogs(config, req, filebeatIndexPattern, { clusterUuid, start, end }), ]); const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index dddc44c3c26ea..b9c320732e366 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -9,6 +9,7 @@ import { createMemoryHistory } from 'history'; import React from 'react'; import { Observable } from 'rxjs'; import { AppMountParameters, CoreStart } from 'src/core/public'; +import { themeServiceMock } from '../../../../../src/core/public/mocks'; import { KibanaPageTemplate } from '../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../plugin'; import { createObservabilityRuleTypeRegistryMock } from '../rules/observability_rule_type_registry_mock'; @@ -45,6 +46,7 @@ describe('renderApp', () => { i18n: { Context: ({ children }: { children: React.ReactNode }) => children }, uiSettings: { get: () => false }, http: { basePath: { prepend: (path: string) => path } }, + theme: themeServiceMock.createStartContract(), } as unknown as CoreStart; const config = { unsafe: { @@ -57,6 +59,7 @@ describe('renderApp', () => { element: window.document.createElement('div'), history: createMemoryHistory(), setHeaderActionMenu: () => {}, + theme$: themeServiceMock.createTheme$(), } as unknown as AppMountParameters; expect(() => { diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index ff1b630862e71..69bf9cbe3ce40 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -14,6 +14,7 @@ import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '../../../../.. import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; import { KibanaContextProvider, + KibanaThemeProvider, RedirectAppLinks, } from '../../../../../src/plugins/kibana_react/public'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; @@ -58,7 +59,7 @@ export const renderApp = ({ appMountParameters: AppMountParameters; ObservabilityPageTemplate: React.ComponentType; }) => { - const { element, history } = appMountParameters; + const { element, history, theme$ } = appMountParameters; const i18nCore = core.i18n; const isDarkMode = core.uiSettings.get('theme:darkMode'); @@ -73,30 +74,32 @@ export const renderApp = ({ element.classList.add(APP_WRAPPER_CLASS); ReactDOM.render( - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + , element ); return () => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames.ts index 1b20b82c1202c..35873a31150ac 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames.ts @@ -106,7 +106,7 @@ export const METRIC_JAVA_GC_TIME = 'jvm.gc.time'; export const LABEL_NAME = 'labels.name'; export const HOST = 'host'; -export const HOST_NAME = 'host.hostname'; +export const HOST_HOSTNAME = 'host.hostname'; export const HOST_OS_PLATFORM = 'host.os.platform'; export const CONTAINER_ID = 'container.id'; export const KUBERNETES = 'kubernetes'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx index 440e2d36e2c83..dbd4c08e33cf1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx @@ -31,6 +31,7 @@ export function AddToCaseAction({ lensAttributes, timeRange }: AddToCaseProps) { const { cases, application: { getUrlForApp }, + theme, } = kServices; const getToastText = useCallback( @@ -41,9 +42,10 @@ export function AddToCaseAction({ lensAttributes, timeRange }: AddToCaseProps) { deepLinkId: CasesDeepLinkId.cases, path: generateCaseViewPath({ detailName: theCase.id }), })} - /> + />, + { theme$: theme?.theme$ } ), - [getUrlForApp] + [getUrlForApp, theme?.theme$] ); const absoluteFromDate = parseRelativeDate(timeRange.from); diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index 4462daa1cbf28..72e92649575df 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -72,7 +72,7 @@ function AlertsPage() { const { prepend } = core.http.basePath; const refetch = useRef<() => void>(); const timefilterService = useTimefilterService(); - const { rangeFrom, setRangeFrom, rangeTo, setRangeTo, kuery, setKuery, workflowStatus } = + const { rangeFrom, setRangeFrom, rangeTo, setRangeTo, kuery, setKuery } = useAlertsPageStateContainer(); const { http, @@ -174,15 +174,6 @@ function AlertsPage() { ]; }, [indexNames]); - // Keep the Workflow status code commented (no delete) as requested: https://github.com/elastic/kibana/issues/117686 - - // const setWorkflowStatusFilter = useCallback( - // (value: AlertWorkflowStatus) => { - // setWorkflowStatus(value); - // }, - // [setWorkflowStatus] - // ); - const onQueryChange = useCallback( ({ dateRange, query }) => { if (rangeFrom === dateRange.from && rangeTo === dateRange.to && kuery === (query ?? '')) { @@ -326,8 +317,6 @@ function AlertsPage() { - {/* Keep the Workflow status code commented (no delete) as requested: https://github.com/elastic/kibana/issues/117686*/} - {/* */} @@ -339,7 +328,6 @@ function AlertsPage() { rangeFrom={rangeFrom} rangeTo={rangeTo} kuery={kuery} - workflowStatus={workflowStatus} setRefetch={setRefetch} /> diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx index cc455567d0079..44557cbcc8a2a 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx @@ -14,7 +14,6 @@ import { ALERT_DURATION, ALERT_REASON, ALERT_STATUS, - ALERT_WORKFLOW_STATUS, TIMESTAMP, } from '@kbn/rule-data-utils/technical_field_names'; @@ -31,7 +30,7 @@ import { import styled from 'styled-components'; import React, { Suspense, useMemo, useState, useCallback, useEffect } from 'react'; -import usePrevious from 'react-use/lib/usePrevious'; + import { pick } from 'lodash'; import { getAlertsPermissions } from '../../../../hooks/use_alert_permission'; import type { @@ -46,7 +45,6 @@ import type { TopAlert } from '../alerts_page/alerts_page'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import type { ActionProps, - AlertWorkflowStatus, ColumnHeaderOptions, ControlColumnProps, RowRenderer, @@ -68,12 +66,10 @@ interface AlertsTableTGridProps { rangeFrom: string; rangeTo: string; kuery: string; - workflowStatus: AlertWorkflowStatus; setRefetch: (ref: () => void) => void; } interface ObservabilityActionsProps extends ActionProps { - currentStatus: AlertWorkflowStatus; setFlyoutAlert: React.Dispatch>; } @@ -137,11 +133,7 @@ function ObservabilityActions({ data, eventId, ecsData, - currentStatus, - refetch, setFlyoutAlert, - setEventsLoading, - setEventsDeleted, }: ObservabilityActionsProps) { const { core, observabilityRuleTypeRegistry } = usePluginContext(); const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); @@ -155,14 +147,6 @@ function ObservabilityActions({ () => parseAlert(observabilityRuleTypeRegistry), [observabilityRuleTypeRegistry] ); - // const alertDataConsumer = useMemo( - // () => get(dataFieldEs, ALERT_RULE_CONSUMER, [''])[0], - // [dataFieldEs] - // ); - // const alertDataProducer = useMemo( - // () => get(dataFieldEs, ALERT_RULE_PRODUCER, [''])[0], - // [dataFieldEs] - // ); const alert = parseObservabilityAlert(dataFieldEs); const { prepend } = core.http.basePath; @@ -188,30 +172,6 @@ function ObservabilityActions({ }; }, [data, eventId, ecsData]); - // Hide the WorkFlow filter, but keep its code as required in https://github.com/elastic/kibana/issues/117686 - - // const onAlertStatusUpdated = useCallback(() => { - // setActionsPopover(null); - // if (refetch) { - // refetch(); - // } - // }, [setActionsPopover, refetch]); - - // const alertPermissions = useGetUserAlertsPermissions( - // capabilities, - // alertDataConsumer === 'alerts' ? alertDataProducer : alertDataConsumer - // ); - - // const statusActionItems = useStatusBulkActionItems({ - // eventIds: [eventId], - // currentStatus, - // indexName: ecsData._index ?? '', - // setEventsLoading, - // setEventsDeleted, - // onUpdateSuccess: onAlertStatusUpdated, - // onUpdateFailure: onAlertStatusUpdated, - // }); - const ruleId = alert.fields['kibana.alert.rule.uuid'] ?? null; const linkToRule = ruleId ? prepend(paths.management.ruleDetails(ruleId)) : null; @@ -235,8 +195,7 @@ function ObservabilityActions({ }), ] : []), - // Hide the WorkFlow filter, but keep its code as required in https://github.com/elastic/kibana/issues/117686 - // ...(alertPermissions.crud ? statusActionItems : []), + ...(!!linkToRule ? [ ); } -// Hide the WorkFlow filter, but keep its code as required in https://github.com/elastic/kibana/issues/117686 - const FIELDS_WITHOUT_CELL_ACTIONS = [ '@timestamp', 'signal.rule.risk_score', @@ -320,8 +277,8 @@ const FIELDS_WITHOUT_CELL_ACTIONS = [ ]; export function AlertsTableTGrid(props: AlertsTableTGridProps) { - const { indexNames, rangeFrom, rangeTo, kuery, workflowStatus, setRefetch } = props; - const prevWorkflowStatus = usePrevious(workflowStatus); + const { indexNames, rangeFrom, rangeTo, kuery, setRefetch } = props; + const { timelines, application: { capabilities }, @@ -346,12 +303,6 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { const [deletedEventIds, setDeletedEventIds] = useState([]); - useEffect(() => { - if (workflowStatus !== prevWorkflowStatus) { - setDeletedEventIds([]); - } - }, [workflowStatus, prevWorkflowStatus]); - useEffect(() => { if (tGridState) { const newState = JSON.stringify({ @@ -385,14 +336,13 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { ); }, }, ]; - }, [workflowStatus, setEventsDeleted]); + }, [setEventsDeleted]); const onStateChange = useCallback( (state: TGridState) => { @@ -418,8 +368,6 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { type, columns: tGridState?.columns ?? columns, deletedEventIds, - // Hide the WorkFlow filter, but keep its code as required in https://github.com/elastic/kibana/issues/117686 - // defaultCellActions: getDefaultCellActions({ addToQuery }), disabledCellActions: FIELDS_WITHOUT_CELL_ACTIONS, end: rangeTo, filters: [], @@ -430,7 +378,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { footerText: translations.alertsTable.footerTextLabel, onStateChange, query: { - query: `${ALERT_WORKFLOW_STATUS}: ${workflowStatus}${kuery !== '' ? ` and ${kuery}` : ''}`, + query: kuery, language: 'kuery', }, renderCellValue: getRenderCellValue({ setFlyoutAlert }), @@ -447,7 +395,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { sortDirection, }, ], - filterStatus: workflowStatus as AlertWorkflowStatus, + leadingControlColumns, trailingControlColumns, unit: (totalAlerts: number) => translations.alertsTable.showingAlertsTitle(totalAlerts), @@ -457,7 +405,6 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { rangeTo, hasAlertsCrudPermissions, indexNames, - workflowStatus, kuery, rangeFrom, setRefetch, diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/state_container.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/state_container/state_container.tsx index d00109cc5d63f..02311833325f3 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/state_container.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/state_container/state_container.tsx @@ -9,13 +9,11 @@ import { createStateContainer, createStateContainerReactHelpers, } from '../../../../../../../../src/plugins/kibana_utils/public'; -import type { AlertWorkflowStatus } from '../../../../../common/typings'; interface AlertsPageContainerState { rangeFrom: string; rangeTo: string; kuery: string; - workflowStatus: AlertWorkflowStatus; } interface AlertsPageStateTransitions { @@ -24,23 +22,18 @@ interface AlertsPageStateTransitions { ) => (rangeFrom: string) => AlertsPageContainerState; setRangeTo: (state: AlertsPageContainerState) => (rangeTo: string) => AlertsPageContainerState; setKuery: (state: AlertsPageContainerState) => (kuery: string) => AlertsPageContainerState; - setWorkflowStatus: ( - state: AlertsPageContainerState - ) => (workflowStatus: AlertWorkflowStatus) => AlertsPageContainerState; } const defaultState: AlertsPageContainerState = { rangeFrom: 'now-15m', rangeTo: 'now', kuery: '', - workflowStatus: 'open', }; const transitions: AlertsPageStateTransitions = { setRangeFrom: (state) => (rangeFrom) => ({ ...state, rangeFrom }), setRangeTo: (state) => (rangeTo) => ({ ...state, rangeTo }), setKuery: (state) => (kuery) => ({ ...state, kuery }), - setWorkflowStatus: (state) => (workflowStatus) => ({ ...state, workflowStatus }), }; const alertsPageStateContainer = createStateContainer(defaultState, transitions); diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/use_alerts_page_state_container.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/state_container/use_alerts_page_state_container.tsx index 5e81286affba7..0e6c6c7c7a20b 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/use_alerts_page_state_container.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/state_container/use_alerts_page_state_container.tsx @@ -29,11 +29,8 @@ export function useAlertsPageStateContainer() { useUrlStateSyncEffect(stateContainer); - const { setRangeFrom, setRangeTo, setKuery, setWorkflowStatus } = stateContainer.transitions; - const { rangeFrom, rangeTo, kuery, workflowStatus } = useContainerSelector( - stateContainer, - (state) => state - ); + const { setRangeFrom, setRangeTo, setKuery } = stateContainer.transitions; + const { rangeFrom, rangeTo, kuery } = useContainerSelector(stateContainer, (state) => state); return { rangeFrom, @@ -42,8 +39,6 @@ export function useAlertsPageStateContainer() { setRangeTo, kuery, setKuery, - workflowStatus, - setWorkflowStatus, }; } diff --git a/x-pack/plugins/osquery/public/agents/use_agent_status.ts b/x-pack/plugins/osquery/public/agents/use_agent_status.ts index ba2237dbe57ea..a94bb5631343d 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_status.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_status.ts @@ -25,7 +25,7 @@ export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => { ['agentStatus', policyId], () => http.get( - `/internal/osquery/fleet_wrapper/agent-status`, + `/internal/osquery/fleet_wrapper/agent_status`, policyId ? { query: { diff --git a/x-pack/plugins/osquery/public/packs/form/pack_uploader.tsx b/x-pack/plugins/osquery/public/packs/form/pack_uploader.tsx index 9a8156987a783..1687213f1a67e 100644 --- a/x-pack/plugins/osquery/public/packs/form/pack_uploader.tsx +++ b/x-pack/plugins/osquery/public/packs/form/pack_uploader.tsx @@ -47,6 +47,10 @@ const OsqueryPackUploaderComponent: React.FC = ({ onCh // remove any multiple spaces from the query return value.replaceAll(/\s(?=\s)/gm, ''); } + if (key === 'interval') { + // convert interval int to string + return `${value}`; + } return value; }); diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index 9eb5cf3fee07d..df9829407043c 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -131,12 +131,12 @@ function getLensAttributes( references: [ { id: 'logs-*', - name: 'dataView-datasource-current-dataView', + name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern', }, { id: 'logs-*', - name: 'dataView-datasource-layer-layer1', + name: 'indexpattern-datasource-layer-layer1', type: 'index-pattern', }, { diff --git a/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_status_for_agent_policy.ts b/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_status_for_agent_policy.ts index 1f4f12648a25b..18fc42d725c10 100644 --- a/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_status_for_agent_policy.ts +++ b/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_status_for_agent_policy.ts @@ -17,7 +17,7 @@ export const getAgentStatusForAgentPolicyRoute = ( ) => { router.get( { - path: '/internal/osquery/fleet_wrapper/agent-status', + path: '/internal/osquery/fleet_wrapper/agent_status', validate: { query: schema.object({ policyId: schema.string(), diff --git a/x-pack/plugins/painless_lab/public/application/index.tsx b/x-pack/plugins/painless_lab/public/application/index.tsx index 143f32b3d5def..81696c6f568bb 100644 --- a/x-pack/plugins/painless_lab/public/application/index.tsx +++ b/x-pack/plugins/painless_lab/public/application/index.tsx @@ -7,9 +7,10 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { CoreSetup, CoreStart } from 'kibana/public'; -import { HttpSetup, ChromeStart } from 'src/core/public'; -import { createKibanaReactContext } from '../../../../../src/plugins/kibana_react/public'; +import { Observable } from 'rxjs'; +import { CoreSetup, CoreStart, HttpSetup, ChromeStart, CoreTheme } from 'src/core/public'; + +import { createKibanaReactContext, KibanaThemeProvider } from '../shared_imports'; import { Links } from '../links'; import { AppContextProvider } from './context'; @@ -21,11 +22,12 @@ interface AppDependencies { uiSettings: CoreSetup['uiSettings']; links: Links; chrome: ChromeStart; + theme$: Observable; } export function renderApp( element: HTMLElement | null, - { http, I18nContext, uiSettings, links, chrome }: AppDependencies + { http, I18nContext, uiSettings, links, chrome, theme$ }: AppDependencies ) { if (!element) { return () => undefined; @@ -35,11 +37,13 @@ export function renderApp( }); render( - - -
- - + + + +
+ + + , element ); diff --git a/x-pack/plugins/painless_lab/public/plugin.tsx b/x-pack/plugins/painless_lab/public/plugin.tsx index 793947b9803c9..0a8499b2f5b7b 100644 --- a/x-pack/plugins/painless_lab/public/plugin.tsx +++ b/x-pack/plugins/painless_lab/public/plugin.tsx @@ -50,7 +50,7 @@ export class PainlessLabUIPlugin implements Plugin { + mount: async ({ element, theme$ }) => { const [core] = await getStartServices(); const { @@ -76,6 +76,7 @@ export class PainlessLabUIPlugin implements Plugin { diff --git a/x-pack/plugins/painless_lab/public/shared_imports.ts b/x-pack/plugins/painless_lab/public/shared_imports.ts new file mode 100644 index 0000000000000..92e3b31f921ba --- /dev/null +++ b/x-pack/plugins/painless_lab/public/shared_imports.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 { + createKibanaReactContext, + KibanaThemeProvider, +} from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/remote_clusters/public/application/index.d.ts b/x-pack/plugins/remote_clusters/public/application/index.d.ts index 45f981b5f2bc5..588d18263df48 100644 --- a/x-pack/plugins/remote_clusters/public/application/index.d.ts +++ b/x-pack/plugins/remote_clusters/public/application/index.d.ts @@ -4,8 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { Observable } from 'rxjs'; -import { ScopedHistory } from 'kibana/public'; +import { ScopedHistory, CoreTheme } from 'kibana/public'; import { RegisterManagementAppArgs, I18nStart } from '../types'; export declare const renderApp: ( @@ -15,5 +16,6 @@ export declare const renderApp: ( isCloudEnabled: boolean; cloudBaseUrl: string; }, - history: ScopedHistory + history: ScopedHistory, + theme$: Observable ) => ReturnType; diff --git a/x-pack/plugins/remote_clusters/public/application/index.js b/x-pack/plugins/remote_clusters/public/application/index.js index e9fb87a9c5f94..01a6e20222210 100644 --- a/x-pack/plugins/remote_clusters/public/application/index.js +++ b/x-pack/plugins/remote_clusters/public/application/index.js @@ -9,20 +9,23 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Provider } from 'react-redux'; +import { KibanaThemeProvider } from '../shared_imports'; import { App } from './app'; import { remoteClustersStore } from './store'; import { AppContextProvider } from './app_context'; import './_hacks.scss'; -export const renderApp = (elem, I18nContext, appDependencies, history) => { +export const renderApp = (elem, I18nContext, appDependencies, history, theme$) => { render( - - - - - + + + + + + + , elem ); diff --git a/x-pack/plugins/remote_clusters/public/plugin.ts b/x-pack/plugins/remote_clusters/public/plugin.ts index be9e1bcceb219..4b47d76944b77 100644 --- a/x-pack/plugins/remote_clusters/public/plugin.ts +++ b/x-pack/plugins/remote_clusters/public/plugin.ts @@ -43,7 +43,7 @@ export class RemoteClustersUIPlugin defaultMessage: 'Remote Clusters', }), order: 7, - mount: async ({ element, setBreadcrumbs, history }) => { + mount: async ({ element, setBreadcrumbs, history, theme$ }) => { const [core] = await getStartServices(); const { chrome: { docTitle }, @@ -69,7 +69,8 @@ export class RemoteClustersUIPlugin element, i18nContext, { isCloudEnabled, cloudBaseUrl }, - history + history, + theme$ ); return () => { diff --git a/x-pack/plugins/remote_clusters/public/shared_imports.ts b/x-pack/plugins/remote_clusters/public/shared_imports.ts index c8d7f1d9f13f3..55d963e2a29b7 100644 --- a/x-pack/plugins/remote_clusters/public/shared_imports.ts +++ b/x-pack/plugins/remote_clusters/public/shared_imports.ts @@ -10,3 +10,5 @@ export { indices, SectionLoading, } from '../../../../src/plugins/es_ui_shared/public'; + +export { KibanaThemeProvider } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index b55b7e636472c..380857f1bffd2 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -147,7 +147,9 @@ export class ReportingCsvPanelAction implements ActionDefinition const blob = new Blob([rawResponse as BlobPart], { type: 'text/csv;charset=utf-8;' }); // Hack for IE11 Support + // @ts-expect-error if (window.navigator.msSaveOrOpenBlob) { + // @ts-expect-error return window.navigator.msSaveOrOpenBlob(blob, download); } diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts index 4d883eb9aefb9..ffc9fadd55fe1 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts @@ -49,6 +49,10 @@ const searchSourceMock = { ...searchSourceInstanceMock }; const mockSearchSourceService: jest.Mocked = { create: jest.fn().mockReturnValue(searchSourceMock), createEmpty: jest.fn().mockReturnValue(searchSourceMock), + telemetry: jest.fn(), + inject: jest.fn(), + extract: jest.fn(), + getAllMigrations: jest.fn(), }; const mockDataClientSearchDefault = jest.fn().mockImplementation( (): Rx.Observable<{ rawResponse: SearchResponse }> => @@ -338,7 +342,7 @@ it('uses the scrollId to page all the data', async () => { expect(mockDataClient.search).toHaveBeenCalledTimes(1); expect(mockDataClient.search).toBeCalledWith( - { params: { ignore_throttled: true, scroll: '30s', size: 500 } }, + { params: { ignore_throttled: undefined, scroll: '30s', size: 500 } }, { strategy: 'es' } ); @@ -815,3 +819,30 @@ describe('formulas', () => { expect(csvResult.csv_contains_formulas).toBe(true); }); }); + +it('can override ignoring frozen indices', async () => { + const originalGet = uiSettingsClient.get; + uiSettingsClient.get = jest.fn().mockImplementation((key): any => { + if (key === 'search:includeFrozen') { + return true; + } + return originalGet(key); + }); + + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { es: mockEsClient, data: mockDataClient, uiSettings: uiSettingsClient }, + { searchSourceStart: mockSearchSourceService, fieldFormatsRegistry: mockFieldFormatsRegistry }, + new CancellationToken(), + logger, + stream + ); + + await generateCsv.generateData(); + + expect(mockDataClient.search).toBeCalledWith( + { params: { ignore_throttled: false, scroll: '30s', size: 500 } }, + { strategy: 'es' } + ); +}); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 4f2eb9109c958..1214061748d10 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -94,7 +94,7 @@ export class CsvGenerator { index: index.title, scroll: scrollSettings.duration, size: scrollSettings.size, - ignore_throttled: !includeFrozen, + ignore_throttled: includeFrozen ? false : undefined, // "true" will cause deprecation warnings logged in ES }, }; diff --git a/x-pack/plugins/rollup/public/application.tsx b/x-pack/plugins/rollup/public/application.tsx index 35b4ad8fb489f..3bebe4597a08a 100644 --- a/x-pack/plugins/rollup/public/application.tsx +++ b/x-pack/plugins/rollup/public/application.tsx @@ -6,10 +6,11 @@ */ import React from 'react'; -import { CoreSetup } from 'kibana/public'; import { render, unmountComponentAtNode } from 'react-dom'; import { Provider } from 'react-redux'; -import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; + +import { CoreSetup } from 'kibana/public'; +import { KibanaContextProvider, KibanaThemeProvider } from './shared_imports'; // @ts-ignore import { rollupJobsStore } from './crud_app/store'; // @ts-ignore @@ -24,7 +25,7 @@ import { ManagementAppMountParams } from '../../../../src/plugins/management/pub */ export const renderApp = async ( core: CoreSetup, - { history, element, setBreadcrumbs }: ManagementAppMountParams + { history, element, setBreadcrumbs, theme$ }: ManagementAppMountParams ) => { const [coreStart] = await core.getStartServices(); const I18nContext = coreStart.i18n.Context; @@ -36,11 +37,13 @@ export const renderApp = async ( render( - - - - - + + + + + + + , element ); diff --git a/x-pack/plugins/rollup/public/shared_imports.ts b/x-pack/plugins/rollup/public/shared_imports.ts index 3478198dd9b68..cb100f2df26f7 100644 --- a/x-pack/plugins/rollup/public/shared_imports.ts +++ b/x-pack/plugins/rollup/public/shared_imports.ts @@ -11,3 +11,8 @@ export { SectionLoading, EuiCodeEditor, } from '../../../../src/plugins/es_ui_shared/public'; + +export { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/rule_registry/server/config.ts b/x-pack/plugins/rule_registry/server/config.ts index 4b691c15d1b3c..7f3a3db42556e 100644 --- a/x-pack/plugins/rule_registry/server/config.ts +++ b/x-pack/plugins/rule_registry/server/config.ts @@ -32,4 +32,3 @@ export const config: PluginConfigDescriptor = { export type RuleRegistryPluginConfig = TypeOf; export const INDEX_PREFIX = '.alerts' as const; -export const INDEX_PREFIX_FOR_BACKING_INDICES = '.internal.alerts' as const; diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.mock.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.mock.ts index 63a159121e009..3daf5cd722ce9 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.mock.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.mock.ts @@ -29,6 +29,7 @@ export const createRuleDataClientMock = ( indexName, kibanaVersion: '7.16.0', isWriteEnabled: jest.fn(() => true), + indexNameWithNamespace: jest.fn((namespace: string) => indexName + namespace), // @ts-ignore 4.3.5 upgrade getReader: jest.fn((_options?: { namespace?: string }) => ({ diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts index d7ec6ea41ac8f..4aa0126cdabf8 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts @@ -54,6 +54,10 @@ export class RuleDataClient implements IRuleDataClient { return this.options.indexInfo.kibanaVersion; } + public indexNameWithNamespace(namespace: string): string { + return this.options.indexInfo.getPrimaryAlias(namespace); + } + private get writeEnabled(): boolean { return this._isWriteEnabled; } @@ -192,7 +196,7 @@ export class RuleDataClient implements IRuleDataClient { return clusterClient.bulk(requestWithDefaultParameters).then((response) => { if (response.body.errors) { const error = new errors.ResponseError(response); - throw error; + this.options.logger.error(error); } return response; }); diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts index 5ddbd0035526d..e970a13c78aaa 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts @@ -10,10 +10,11 @@ import { BulkRequest, BulkResponse } from '@elastic/elasticsearch/lib/api/typesW import { ESSearchRequest, ESSearchResponse } from 'src/core/types/elasticsearch'; import { FieldDescriptor } from 'src/plugins/data/server'; -import { TechnicalRuleDataFieldName } from '../../common/technical_rule_data_field_names'; +import { ParsedTechnicalFields } from '../../common/parse_technical_fields'; export interface IRuleDataClient { indexName: string; + indexNameWithNamespace(namespace: string): string; kibanaVersion: string; isWriteEnabled(): boolean; getReader(options?: { namespace?: string }): IRuleDataReader; @@ -23,9 +24,7 @@ export interface IRuleDataClient { export interface IRuleDataReader { search( request: TSearchRequest - ): Promise< - ESSearchResponse>, TSearchRequest> - >; + ): Promise, TSearchRequest>>; getDynamicIndexPattern(target?: string): Promise<{ title: string; diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index_info.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index_info.ts index 52fef63a732f0..eca44c550411f 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index_info.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index_info.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { INDEX_PREFIX, INDEX_PREFIX_FOR_BACKING_INDICES } from '../config'; +import { INDEX_PREFIX } from '../config'; import { IndexOptions } from './index_options'; import { joinWithDash } from './utils'; @@ -23,16 +23,16 @@ interface ConstructorOptions { export class IndexInfo { constructor(options: ConstructorOptions) { const { indexOptions, kibanaVersion } = options; - const { registrationContext, dataset } = indexOptions; + const { registrationContext, dataset, additionalPrefix } = indexOptions; this.indexOptions = indexOptions; this.kibanaVersion = kibanaVersion; - this.baseName = joinWithDash(INDEX_PREFIX, `${registrationContext}.${dataset}`); - this.basePattern = joinWithDash(this.baseName, '*'); - this.baseNameForBackingIndices = joinWithDash( - INDEX_PREFIX_FOR_BACKING_INDICES, + this.baseName = joinWithDash( + `${additionalPrefix ?? ''}${INDEX_PREFIX}`, `${registrationContext}.${dataset}` ); + this.basePattern = joinWithDash(this.baseName, '*'); + this.baseNameForBackingIndices = `.internal${this.baseName}`; } /** diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index_options.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index_options.ts index ba0961c7926a1..cdec7c609699d 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index_options.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index_options.ts @@ -95,6 +95,17 @@ export interface IndexOptions { * @example '.siem-signals', undefined */ secondaryAlias?: string; + + /** + * Optional prefix name that will be prepended to indices in addition to + * primary dataset and context naming convention. + * + * Currently used only for creating a preview index for the purpose of + * previewing alerts from a rule. The documents are identical to alerts, but + * shouldn't exist on an alert index and shouldn't be queried together with + * real alerts in any way, because the rule that created them doesn't exist + */ + additionalPrefix?: string; } /** diff --git a/x-pack/plugins/rule_registry/server/types.ts b/x-pack/plugins/rule_registry/server/types.ts index c524a5412e13d..1ea7a66c8fdeb 100644 --- a/x-pack/plugins/rule_registry/server/types.ts +++ b/x-pack/plugins/rule_registry/server/types.ts @@ -12,14 +12,14 @@ import { AlertTypeParams, AlertTypeState, } from '../../alerting/common'; -import { AlertExecutorOptions, AlertServices, AlertType } from '../../alerting/server'; +import { AlertExecutorOptions, AlertServices, RuleType } from '../../alerting/server'; import { AlertsClient } from './alert_data_client/alerts_client'; type SimpleAlertType< TState extends AlertTypeState, TParams extends AlertTypeParams = {}, TAlertInstanceContext extends AlertInstanceContext = {} -> = AlertType; +> = RuleType; export type AlertTypeExecutor< TState extends AlertTypeState, @@ -38,7 +38,7 @@ export type AlertTypeWithExecutor< TAlertInstanceContext extends AlertInstanceContext = {}, TServices extends Record = {} > = Omit< - AlertType, + RuleType, 'executor' > & { executor: AlertTypeExecutor; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts index 2c5fe09d80563..d1c20e0667e24 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts @@ -127,7 +127,7 @@ describe('createLifecycleExecutor', () => { hits: { hits: [ { - fields: { + _source: { '@timestamp': '', [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_UUID]: 'ALERT_0_UUID', @@ -144,7 +144,7 @@ describe('createLifecycleExecutor', () => { }, }, { - fields: { + _source: { '@timestamp': '', [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_UUID]: 'ALERT_1_UUID', @@ -247,7 +247,7 @@ describe('createLifecycleExecutor', () => { hits: { hits: [ { - fields: { + _source: { '@timestamp': '', [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_UUID]: 'ALERT_0_UUID', @@ -263,7 +263,7 @@ describe('createLifecycleExecutor', () => { }, }, { - fields: { + _source: { '@timestamp': '', [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_UUID]: 'ALERT_1_UUID', diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts index c30b1654a3587..0ca0002470af0 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts @@ -18,7 +18,7 @@ import { AlertTypeParams, AlertTypeState, } from '../../../alerting/server'; -import { ParsedTechnicalFields, parseTechnicalFields } from '../../common/parse_technical_fields'; +import { ParsedTechnicalFields } from '../../common/parse_technical_fields'; import { ALERT_DURATION, ALERT_END, @@ -216,8 +216,6 @@ export const createLifecycleExecutor = collapse: { field: ALERT_UUID, }, - _source: false, - fields: [{ field: '*', include_unmapped: true }], sort: { [TIMESTAMP]: 'desc' as const, }, @@ -226,13 +224,13 @@ export const createLifecycleExecutor = }); hits.hits.forEach((hit) => { - const fields = parseTechnicalFields(hit.fields); - const indexName = hit._index; - const alertId = fields[ALERT_INSTANCE_ID]; - trackedAlertsDataMap[alertId] = { - indexName, - fields, - }; + const alertId = hit._source[ALERT_INSTANCE_ID]; + if (alertId) { + trackedAlertsDataMap[alertId] = { + indexName: hit._index, + fields: hit._source, + }; + } }); } diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index f0e2412629bb1..7aa7dcd9620fe 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -14,7 +14,7 @@ import { ALERT_UUID, } from '@kbn/rule-data-utils'; import { loggerMock } from '@kbn/logging/mocks'; -import { castArray, omit, mapValues } from 'lodash'; +import { castArray, omit } from 'lodash'; import { RuleDataClient } from '../rule_data_client'; import { createRuleDataClientMock } from '../rule_data_client/rule_data_client.mock'; import { createLifecycleRuleTypeFactory } from './create_lifecycle_rule_type_factory'; @@ -293,14 +293,10 @@ describe('createLifecycleRuleTypeFactory', () => { (doc: any) => !('index' in doc) && doc['service.name'] === 'opbeans-node' ) as Record; - const stored = mapValues(lastOpbeansNodeDoc, (val) => { - return castArray(val); - }); - // @ts-ignore 4.3.5 upgrade helpers.ruleDataClientMock.getReader().search.mockResolvedValueOnce({ hits: { - hits: [{ fields: stored } as any], + hits: [{ _source: lastOpbeansNodeDoc } as any], total: { value: 1, relation: 'eq', @@ -378,13 +374,9 @@ describe('createLifecycleRuleTypeFactory', () => { (doc: any) => !('index' in doc) && doc['service.name'] === 'opbeans-node' ) as Record; - const stored = mapValues(lastOpbeansNodeDoc, (val) => { - return castArray(val); - }); - helpers.ruleDataClientMock.getReader().search.mockResolvedValueOnce({ hits: { - hits: [{ fields: stored } as any], + hits: [{ _source: lastOpbeansNodeDoc } as any], total: { value: 1, relation: 'eq', diff --git a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts index de1193771dd95..2d914e5e0945e 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts @@ -106,14 +106,16 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper } return { - createdAlerts: augmentedAlerts.map((alert, idx) => { - const responseItem = response.body.items[idx].create; - return { - _id: responseItem?._id ?? '', - _index: responseItem?._index ?? '', - ...alert._source, - }; - }), + createdAlerts: augmentedAlerts + .map((alert, idx) => { + const responseItem = response.body.items[idx].create; + return { + _id: responseItem?._id ?? '', + _index: responseItem?._index ?? '', + ...alert._source, + }; + }) + .filter((_, idx) => response.body.items[idx].create?.status === 201), }; } else { logger.debug('Writing is disabled.'); diff --git a/x-pack/plugins/rule_registry/server/utils/persistence_types.ts b/x-pack/plugins/rule_registry/server/utils/persistence_types.ts index 5541bc6a6d00e..61136b432a552 100644 --- a/x-pack/plugins/rule_registry/server/utils/persistence_types.ts +++ b/x-pack/plugins/rule_registry/server/utils/persistence_types.ts @@ -10,7 +10,7 @@ import { AlertExecutorOptions, AlertInstanceContext, AlertInstanceState, - AlertType, + RuleType, AlertTypeParams, AlertTypeState, } from '../../../alerting/server'; @@ -39,7 +39,7 @@ export type PersistenceAlertType< TInstanceContext extends AlertInstanceContext = {}, TActionGroupIds extends string = never > = Omit< - AlertType, + RuleType, 'executor' > & { executor: ( @@ -65,4 +65,4 @@ export type CreatePersistenceRuleTypeWrapper = (options: { TActionGroupIds extends string = never >( type: PersistenceAlertType -) => AlertType; +) => RuleType; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts b/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts index c20fa83738f9d..aa2e87a71fcbb 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts @@ -5,9 +5,13 @@ * 2.0. */ -import { SavedObjectsClientContract, SavedObjectsFindOptionsReference } from 'src/core/server'; +import { + SavedObjectsClientContract, + SavedObjectsFindOptionsReference, + SavedObject, +} from 'src/core/server'; import { tagSavedObjectTypeName } from '../../../common/constants'; -import { Tag, TagWithRelations } from '../../../common/types'; +import { Tag, TagAttributes, TagWithRelations } from '../../../common/types'; export const addConnectionCount = async ( tags: Tag[], @@ -22,14 +26,19 @@ export const addConnectionCount = async ( id, })); - const allResults = await client.find({ + const pitFinder = client.createPointInTimeFinder({ type: targetTypes, - page: 1, - perPage: 10000, + perPage: 1000, hasReference: references, hasReferenceOperator: 'OR', }); - allResults.saved_objects.forEach((obj) => { + + const results: SavedObject[] = []; + for await (const response of pitFinder.find()) { + results.push(...response.saved_objects); + } + + results.forEach((obj) => { obj.references.forEach((ref) => { if (ref.type === tagSavedObjectTypeName && ids.has(ref.id)) { counts.set(ref.id, counts.get(ref.id)! + 1); diff --git a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts index 7d0eff3f77296..eb6f7eec3075f 100644 --- a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts @@ -194,11 +194,19 @@ describe('TagsClient', () => { it('calls `soClient.find` with the correct parameters', async () => { await tagsClient.getAll(); - expect(soClient.find).toHaveBeenCalledTimes(1); - expect(soClient.find).toHaveBeenCalledWith({ + expect(soClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(soClient.createPointInTimeFinder).toHaveBeenCalledWith({ type: 'tag', - perPage: 10000, + perPage: 1000, }); + + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tag', + perPage: 1000, + }) + ); }); it('converts the objects returned from the soClient to tags', async () => { diff --git a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts index 2b301652fa38d..32bb5d6e4e3eb 100644 --- a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts @@ -48,12 +48,18 @@ export class TagsClient implements ITagsClient { } public async getAll() { - const result = await this.soClient.find({ + const pitFinder = this.soClient.createPointInTimeFinder({ type: this.type, - perPage: 10000, + perPage: 1000, }); - return result.saved_objects.map(savedObjectToTag); + const results: TagSavedObject[] = []; + for await (const response of pitFinder.find()) { + results.push(...response.saved_objects); + } + await pitFinder.close(); + + return results.map(savedObjectToTag); } public async delete(id: string) { diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts index 23e276541465a..7d9813928f924 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts @@ -7,7 +7,7 @@ import puppeteer from 'puppeteer'; import * as Rx from 'rxjs'; -import { take } from 'rxjs/operators'; +import { mergeMap, take } from 'rxjs/operators'; import type { Logger } from 'src/core/server'; import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; import { ConfigType } from '../../../config'; @@ -27,6 +27,7 @@ describe('HeadlessChromiumDriverFactory', () => { let logger: jest.Mocked; let screenshotMode: jest.Mocked; let factory: HeadlessChromiumDriverFactory; + let mockBrowser: jest.Mocked; beforeEach(async () => { logger = { @@ -38,7 +39,8 @@ describe('HeadlessChromiumDriverFactory', () => { } as unknown as typeof logger; screenshotMode = {} as unknown as typeof screenshotMode; - (puppeteer as jest.Mocked).launch.mockResolvedValue({ + let pageClosed = false; + mockBrowser = { newPage: jest.fn().mockResolvedValue({ target: jest.fn(() => ({ createCDPSession: jest.fn().mockResolvedValue({ @@ -47,10 +49,17 @@ describe('HeadlessChromiumDriverFactory', () => { })), emulateTimezone: jest.fn(), setDefaultTimeout: jest.fn(), + isClosed: jest.fn(() => { + return pageClosed; + }), + }), + close: jest.fn(() => { + pageClosed = true; }), - close: jest.fn(), process: jest.fn(), - } as unknown as puppeteer.Browser); + } as unknown as jest.Mocked; + + (puppeteer as jest.Mocked).launch.mockResolvedValue(mockBrowser); factory = new HeadlessChromiumDriverFactory(screenshotMode, config, logger, path); jest.spyOn(factory, 'getBrowserLogger').mockReturnValue(Rx.EMPTY); @@ -59,13 +68,14 @@ describe('HeadlessChromiumDriverFactory', () => { }); describe('createPage', () => { - it('returns browser driver and process exit observable', async () => { + it('returns browser driver, unexpected process exit observable, and close callback', async () => { await expect( factory.createPage({ openUrlTimeout: 0 }).pipe(take(1)).toPromise() ).resolves.toEqual( expect.objectContaining({ driver: expect.anything(), - exit$: expect.anything(), + unexpectedExit$: expect.anything(), + close: expect.anything(), }) ); }); @@ -80,5 +90,24 @@ describe('HeadlessChromiumDriverFactory', () => { `"Error spawning Chromium browser! Puppeteer Launch mock fail."` ); }); + + describe('close behaviour', () => { + it('does not allow close to be called on the browse more than once', async () => { + await factory + .createPage({ openUrlTimeout: 0 }) + .pipe( + take(1), + mergeMap(async ({ close }) => { + expect(mockBrowser.close).not.toHaveBeenCalled(); + await close().toPromise(); + await close().toPromise(); + expect(mockBrowser.close).toHaveBeenCalledTimes(1); + }) + ) + .toPromise(); + // Check again, after the observable completes + expect(mockBrowser.close).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts index e9656013140c2..16b09d92dc2c0 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts @@ -33,8 +33,16 @@ interface CreatePageOptions { interface CreatePageResult { driver: HeadlessChromiumDriver; - exit$: Rx.Observable; + unexpectedExit$: Rx.Observable; metrics$: Rx.Observable; + /** + * Close the page and the browser. + * + * @note Ensure this function gets called once all actions against the page + * have concluded. This ensures the browser is closed and gives the OS a chance + * to reclaim resources like memory. + */ + close: () => Rx.Observable; } export const DEFAULT_VIEWPORT = { @@ -152,7 +160,8 @@ export class HeadlessChromiumDriverFactory { logger.debug(`Browser page driver created`); const childProcess = { - async kill() { + async kill(): Promise { + if (page.isClosed()) return; try { if (devTools && startMetrics) { const endMetrics = await devTools.send('Performance.getMetrics'); @@ -171,7 +180,9 @@ export class HeadlessChromiumDriverFactory { } try { + logger.debug('Attempting to close browser...'); await browser?.close(); + logger.debug('Browser closed.'); } catch (err) { // do not throw logger.error(err); @@ -180,10 +191,10 @@ export class HeadlessChromiumDriverFactory { }; const { terminate$ } = safeChildProcess(logger, childProcess); - // this is adding unsubscribe logic to our observer - // so that if our observer unsubscribes, we terminate our child-process + // Ensure that the browser is closed once the observable completes. observer.add(() => { - logger.debug(`The browser process observer has unsubscribed. Closing the browser...`); + if (page.isClosed()) return; // avoid emitting a log unnecessarily + logger.debug(`It looks like the browser is no longer being used. Closing the browser...`); childProcess.kill(); // ignore async }); @@ -207,9 +218,14 @@ export class HeadlessChromiumDriverFactory { const driver = new HeadlessChromiumDriver(this.screenshotMode, this.config, page); // Rx.Observable: stream to interrupt page capture - const exit$ = this.getPageExit(browser, page); + const unexpectedExit$ = this.getPageExit(browser, page); - observer.next({ driver, exit$, metrics$: metrics$.asObservable() }); + observer.next({ + driver, + unexpectedExit$, + metrics$: metrics$.asObservable(), + close: () => Rx.from(childProcess.kill()), + }); // unsubscribe logic makes a best-effort attempt to delete the user data directory used by chromium observer.add(() => { diff --git a/x-pack/plugins/screenshotting/server/browsers/mock.ts b/x-pack/plugins/screenshotting/server/browsers/mock.ts index 4b9142b298588..1958f5e6b0396 100644 --- a/x-pack/plugins/screenshotting/server/browsers/mock.ts +++ b/x-pack/plugins/screenshotting/server/browsers/mock.ts @@ -88,7 +88,12 @@ export function createMockBrowserDriverFactory( ): jest.Mocked { return { createPage: jest.fn(() => - of({ driver: driver ?? createMockBrowserDriver(), exit$: NEVER, metrics$: NEVER }) + of({ + driver: driver ?? createMockBrowserDriver(), + unexpectedExit$: NEVER, + metrics$: NEVER, + close: () => of(undefined), + }) ), diagnose: jest.fn(() => of('message')), } as unknown as ReturnType; diff --git a/x-pack/plugins/screenshotting/server/plugin.test.ts b/x-pack/plugins/screenshotting/server/plugin.test.ts new file mode 100644 index 0000000000000..22cd2c2f75ac5 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/plugin.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright 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. + */ + +jest.mock('./browsers/install'); + +import type { CoreSetup, CoreStart, PluginInitializerContext } from 'kibana/server'; +import { coreMock } from 'src/core/server/mocks'; +import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; +import { install } from './browsers/install'; +import { ScreenshottingPlugin } from './plugin'; + +describe('ScreenshottingPlugin', () => { + let initContext: PluginInitializerContext; + let coreSetup: CoreSetup; + let coreStart: CoreStart; + let setupDeps: Parameters[1]; + let plugin: ScreenshottingPlugin; + + beforeEach(() => { + const configSchema = { + browser: { chromium: { disableSandbox: false } }, + }; + initContext = coreMock.createPluginInitializerContext(configSchema); + coreSetup = coreMock.createSetup({}); + coreStart = coreMock.createStart(); + setupDeps = { + screenshotMode: {} as ScreenshotModePluginSetup, + }; + plugin = new ScreenshottingPlugin(initContext); + }); + + describe('setup', () => { + test('returns a setup contract', async () => { + const setupContract = plugin.setup(coreSetup, setupDeps); + expect(setupContract).toEqual({}); + }); + + test('handles setup issues', async () => { + (install as jest.Mock).mockRejectedValue(`Unsupported platform!!!`); + + const setupContract = plugin.setup(coreSetup, setupDeps); + expect(setupContract).toEqual({}); + + await coreSetup.getStartServices(); + + const startContract = plugin.start(coreStart); + expect(startContract).toEqual( + expect.objectContaining({ + diagnose: expect.any(Function), + getScreenshots: expect.any(Function), + }) + ); + }); + }); + + describe('start', () => { + beforeEach(async () => { + plugin.setup(coreSetup, setupDeps); + await coreSetup.getStartServices(); + }); + + test('returns a start contract', async () => { + const startContract = plugin.start(coreStart); + expect(startContract).toEqual( + expect.objectContaining({ + diagnose: expect.any(Function), + getScreenshots: expect.any(Function), + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/plugin.ts b/x-pack/plugins/screenshotting/server/plugin.ts index 53f855e1f544d..a301dd6764367 100755 --- a/x-pack/plugins/screenshotting/server/plugin.ts +++ b/x-pack/plugins/screenshotting/server/plugin.ts @@ -55,22 +55,21 @@ export class ScreenshottingPlugin implements Plugin { - try { - const paths = new ChromiumArchivePaths(); - const logger = this.logger.get('chromium'); - const [config, binaryPath] = await Promise.all([ - createConfig(this.logger, this.config), - install(paths, logger), - ]); + const paths = new ChromiumArchivePaths(); + const logger = this.logger.get('chromium'); + const [config, binaryPath] = await Promise.all([ + createConfig(this.logger, this.config), + install(paths, logger), + ]); - return new HeadlessChromiumDriverFactory(this.screenshotMode, config, logger, binaryPath); - } catch (error) { - this.logger.error('Error in screenshotting setup, it may not function properly.'); - - throw error; - } + return new HeadlessChromiumDriverFactory(this.screenshotMode, config, logger, binaryPath); })(); + this.browserDriverFactory.catch((error) => { + this.logger.error('Error in screenshotting setup, it may not function properly.'); + this.logger.error(error); + }); + return {}; } diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.test.ts b/x-pack/plugins/screenshotting/server/screenshots/index.test.ts index 1fa7eb66192c8..f749e31988ff4 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.test.ts @@ -343,7 +343,12 @@ describe('Screenshot Observable Pipeline', () => { it('observes page exit', async () => { driverFactory.createPage.mockReturnValue( - of({ driver, exit$: throwError('Instant timeout has fired!'), metrics$: NEVER }) + of({ + driver, + unexpectedExit$: throwError('Instant timeout has fired!'), + metrics$: NEVER, + close: () => of(undefined), + }) ); await expect( diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.ts b/x-pack/plugins/screenshotting/server/screenshots/index.ts index e264538d8be39..363d59ccca950 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.ts @@ -11,7 +11,7 @@ import { catchError, concatMap, first, - map, + mapTo, mergeMap, take, takeUntil, @@ -69,13 +69,13 @@ export function getScreenshots( } = options; return browserDriverFactory.createPage({ browserTimezone, openUrlTimeout }, logger).pipe( - mergeMap(({ driver, exit$, metrics$ }) => { + mergeMap(({ driver, unexpectedExit$, metrics$, close }) => { apmCreatePage?.end(); metrics$.subscribe(({ cpu, memory }) => { apmTrans?.setLabel('cpu', cpu, false); apmTrans?.setLabel('memory', memory, false); }); - exit$.subscribe({ error: () => apmTrans?.end() }); + unexpectedExit$.subscribe({ error: () => apmTrans?.end() }); const screen = new ScreenshotObservableHandler(driver, logger, layout, options); @@ -88,13 +88,16 @@ export function getScreenshots( logger.error(error); return of({ ...DEFAULT_SETUP_RESULT, error }); // allow failover screenshot capture }), - takeUntil(exit$), + takeUntil(unexpectedExit$), screen.getScreenshots() ) ), take(options.urls.length), toArray(), - map((results) => ({ layout, metrics$, results })) + mergeMap((results) => { + // At this point we no longer need the page, close it. + return close().pipe(mapTo({ layout, metrics$, results })); + }) ); }), first() diff --git a/x-pack/plugins/searchprofiler/kibana.json b/x-pack/plugins/searchprofiler/kibana.json index 864e3880ae200..995a4fba12d52 100644 --- a/x-pack/plugins/searchprofiler/kibana.json +++ b/x-pack/plugins/searchprofiler/kibana.json @@ -10,5 +10,5 @@ "githubTeam": "kibana-stack-management" }, "requiredPlugins": ["devTools", "home", "licensing"], - "requiredBundles": ["esUiShared"] + "requiredBundles": ["esUiShared", "kibanaReact"] } diff --git a/x-pack/plugins/searchprofiler/public/application/index.tsx b/x-pack/plugins/searchprofiler/public/application/index.tsx index fa4778744bbea..6c1f88c45c1c0 100644 --- a/x-pack/plugins/searchprofiler/public/application/index.tsx +++ b/x-pack/plugins/searchprofiler/public/application/index.tsx @@ -7,9 +7,11 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { HttpStart as Http, ToastsSetup } from 'kibana/public'; +import { Observable } from 'rxjs'; +import { HttpStart as Http, ToastsSetup, CoreTheme } from 'kibana/public'; import { LicenseStatus } from '../../common'; +import { KibanaThemeProvider } from '../shared_imports'; import { App } from './app'; import { AppContextProvider } from './contexts/app_context'; import { ProfileContextProvider } from './contexts/profiler_context'; @@ -20,6 +22,7 @@ interface AppDependencies { I18nContext: any; notifications: ToastsSetup; initialLicenseStatus: LicenseStatus; + theme$: Observable; } export const renderApp = ({ @@ -28,14 +31,17 @@ export const renderApp = ({ I18nContext, notifications, initialLicenseStatus, + theme$, }: AppDependencies) => { render( - - - - - + + + + + + + , el ); diff --git a/x-pack/plugins/searchprofiler/public/plugin.ts b/x-pack/plugins/searchprofiler/public/plugin.ts index 0cc432e15ddda..c903712577b5d 100644 --- a/x-pack/plugins/searchprofiler/public/plugin.ts +++ b/x-pack/plugins/searchprofiler/public/plugin.ts @@ -60,6 +60,7 @@ export class SearchProfilerUIPlugin implements Plugin renders permission denied if required 1`] = ` } > -
- - - -
- - -

- - You need permission to manage roles - -

-
- - - + + +
+
- - -
-

+

+ + You need permission to manage roles + +

+ + - - Contact your system administrator. - -

+ +
+ + +
+

+ + Contact your system administrator. + +

+
+
+ +
- - -
-
+
+
+
+
diff --git a/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap index 8e2b2a9a1f222..99502f13d82c4 100644 --- a/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap +++ b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PromptPage renders as expected with additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; +exports[`PromptPage renders as expected with additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; -exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; +exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; diff --git a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap index 62200cb288e09..720c30e9da211 100644 --- a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We couldn't log you in

We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.

"`; +exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We couldn't log you in

We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.

"`; diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index 8b882c9a6b442..6df764625d709 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts index 7a85e614e4b62..150e878f4297f 100644 --- a/x-pack/plugins/security/server/config_deprecations.test.ts +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -182,7 +182,7 @@ describe('Config Deprecations', () => { }, }; const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); - expect(migrated.security.showInsecureClusterWarning).not.toBeDefined(); + expect(migrated.security?.showInsecureClusterWarning).not.toBeDefined(); expect(migrated.xpack.security.showInsecureClusterWarning).toEqual(false); expect(messages).toMatchInlineSnapshot(` Array [ diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 14e3c8cc95fe6..7fa387207e3ff 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -39,7 +39,7 @@ export const DEFAULT_APP_TIME_RANGE = 'securitySolution:timeDefaults' as const; export const DEFAULT_APP_REFRESH_INTERVAL = 'securitySolution:refreshIntervalDefaults' as const; export const DEFAULT_ALERTS_INDEX = '.alerts-security.alerts' as const; export const DEFAULT_SIGNALS_INDEX = '.siem-signals' as const; -export const DEFAULT_PREVIEW_INDEX = '.siem-preview-signals' as const; +export const DEFAULT_PREVIEW_INDEX = '.preview.alerts-security.alerts' as const; export const DEFAULT_LISTS_INDEX = '.lists' as const; export const DEFAULT_ITEMS_INDEX = '.items' as const; // The DEFAULT_MAX_SIGNALS value exists also in `x-pack/plugins/cases/common/constants.ts` @@ -256,8 +256,6 @@ export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = export const DETECTION_ENGINE_RULES_BULK_ACTION = `${DETECTION_ENGINE_RULES_URL}/_bulk_action` as const; export const DETECTION_ENGINE_RULES_PREVIEW = `${DETECTION_ENGINE_RULES_URL}/preview` as const; -export const DETECTION_ENGINE_RULES_PREVIEW_INDEX_URL = - `${DETECTION_ENGINE_RULES_PREVIEW}/index` as const; /** * Internal detection engine routes diff --git a/x-pack/plugins/security_solution/common/detection_engine/constants.ts b/x-pack/plugins/security_solution/common/detection_engine/constants.ts new file mode 100644 index 0000000000000..7f3c822800673 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/constants.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 enum RULE_PREVIEW_INVOCATION_COUNT { + HOUR = 20, + DAY = 24, + WEEK = 168, + MONTH = 30, +} diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index c5f4e5631e5c8..97079253606f1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -370,6 +370,7 @@ export const previewRulesSchema = t.intersection([ createTypeSpecific, t.type({ invocationCount: t.number }), ]); +export type PreviewRulesSchema = t.TypeOf; type UpdateSchema = SharedUpdateSchema & T; export type EqlUpdateSchema = UpdateSchema>; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/error_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/error_schema.ts index 9a4bd3c65c367..d6e1faa7a5180 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/error_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/error_schema.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { NonEmptyString } from '@kbn/securitysolution-io-ts-types'; import * as t from 'io-ts'; import { rule_id, status_code, message } from '../common/schemas'; @@ -12,7 +13,9 @@ import { rule_id, status_code, message } from '../common/schemas'; // We use id: t.string intentionally and _never_ the id from global schemas as // sometimes echo back out the id that the user gave us and it is not guaranteed // to be a UUID but rather just a string -const partial = t.exact(t.partial({ id: t.string, rule_id })); +const partial = t.exact( + t.partial({ id: t.string, rule_id, list_id: NonEmptyString, item_id: NonEmptyString }) +); const required = t.exact( t.type({ error: t.type({ diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts index ed75823cd30d3..be26f8496c5e9 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts @@ -97,7 +97,7 @@ export async function indexEndpointHostDocs({ client: Client; kbnClient: KbnClient; realPolicies: Record; - epmEndpointPackage: GetPackagesResponse['response'][0]; + epmEndpointPackage: GetPackagesResponse['items'][0]; metadataIndex: string; policyResponseIndex: string; enrollFleet: boolean; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint.ts index 61f7123c36840..a236b56737e03 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint.ts @@ -101,7 +101,7 @@ export const installOrUpgradeEndpointFleetPackage = async ( }) .catch(wrapErrorAndRejectPromise)) as AxiosResponse; - const bulkResp = installEndpointPackageResp.data.response; + const bulkResp = installEndpointPackageResp.data.items; if (bulkResp.length <= 0) { throw new EndpointDataLoadingError( 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 9cbe1e19530ca..f9ecb2e018dff 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1600,7 +1600,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { /** * Generate an EPM Package for Endpoint */ - public generateEpmPackage(): GetPackagesResponse['response'][0] { + public generateEpmPackage(): GetPackagesResponse['items'][0] { return { id: this.seededUUIDv4(), name: 'endpoint', diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index 5bb3bd3dbae52..5c81196a3709c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -124,13 +124,13 @@ export async function indexHostsAndAlerts( const getEndpointPackageInfo = async ( kbnClient: KbnClient -): Promise => { +): Promise => { const endpointPackage = ( (await kbnClient.request({ path: `${EPM_API_ROUTES.LIST_PATTERN}?category=security`, method: 'GET', })) as AxiosResponse - ).data.response.find((epmPackage) => epmPackage.name === 'endpoint'); + ).data.items.find((epmPackage) => epmPackage.name === 'endpoint'); if (!endpointPackage) { throw new Error('EPM Endpoint package was not found!'); diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index f6f5ad4cd23f1..8a9a047aab3fd 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -45,6 +45,7 @@ export interface HostItem { endpoint?: Maybe; host?: Maybe; lastSeen?: Maybe; + risk?: string; } export interface HostValue { diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts new file mode 100644 index 0000000000000..8c58ccaabe8df --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getHostRiskIndex } from '.'; + +describe('hosts risk search_strategy getHostRiskIndex', () => { + it('should properly return index if space is specified', () => { + expect(getHostRiskIndex('testName')).toEqual('ml_host_risk_score_latest_testName'); + }); +}); diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts index 23cda0b68f038..4273c08c638f3 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts @@ -10,12 +10,13 @@ import type { IEsSearchRequest, IEsSearchResponse, } from '../../../../../../../../src/plugins/data/common'; +import { RISKY_HOSTS_INDEX_PREFIX } from '../../../../constants'; import { Inspect, Maybe, TimerangeInput } from '../../../common'; export interface HostsRiskScoreRequestOptions extends IEsSearchRequest { defaultIndex: string[]; factoryQueryType?: FactoryQueryTypes; - hostName?: string; + hostNames?: string[]; timerange?: TimerangeInput; } @@ -38,3 +39,7 @@ export interface RuleRisk { rule_name: string; rule_risk: string; } + +export const getHostRiskIndex = (spaceId: string): string => { + return `${RISKY_HOSTS_INDEX_PREFIX}${spaceId}`; +}; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 60fd126e6fd85..ac8fb19e00df5 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -22,7 +22,6 @@ import { import { FlowTarget } from '../../search_strategy/security_solution/network'; import { errorSchema } from '../../detection_engine/schemas/response/error_schema'; import { Direction, Maybe } from '../../search_strategy'; -import { Ecs } from '../../ecs'; export * from './actions'; export * from './cells'; @@ -503,7 +502,6 @@ export type TimelineExpandedEventType = eventId: string; indexName: string; refetch?: () => void; - ecsData?: Ecs; }; } | EmptyObject; @@ -633,7 +631,7 @@ export interface ColumnHeaderResult { category?: Maybe; columnHeaderType?: Maybe; description?: Maybe; - example?: Maybe; + example?: Maybe; indexes?: Maybe; id?: Maybe; name?: Maybe; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts index 0e87378f4ef96..0e4dbc9a95f9c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts @@ -75,7 +75,9 @@ describe('CTI Enrichment', () => { it('Displays persisted enrichments on the JSON view', () => { const expectedEnrichment = [ { - feed: {}, + feed: { + name: 'AbuseCH malware', + }, indicator: { first_seen: '2021-03-10T08:02:14.000Z', file: { @@ -113,6 +115,7 @@ describe('CTI Enrichment', () => { it('Displays threat indicator details on the threat intel tab', () => { const expectedThreatIndicatorData = [ + { field: 'feed.name', value: 'AbuseCH malware' }, { field: 'indicator.file.hash.md5', value: '9b6c3518a91d23ed77504b5416bfb5b3' }, { field: 'indicator.file.hash.sha256', @@ -173,6 +176,7 @@ describe('CTI Enrichment', () => { const indicatorMatchRuleEnrichment = { field: 'myhash.mysha256', value: 'a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3', + feedName: 'AbuseCH malware', }; const investigationTimeEnrichment = { field: 'source.ip', @@ -188,7 +192,7 @@ describe('CTI Enrichment', () => { .should('exist') .should( 'have.text', - `${indicatorMatchRuleEnrichment.field} ${indicatorMatchRuleEnrichment.value}` + `${indicatorMatchRuleEnrichment.field} ${indicatorMatchRuleEnrichment.value} from ${indicatorMatchRuleEnrichment.feedName}` ); cy.get(`${INVESTIGATION_TIME_ENRICHMENT_SECTION} ${THREAT_DETAILS_ACCORDION}`) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts index 02d8837261f2f..81022a43ff683 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts @@ -174,7 +174,7 @@ describe('Detection rules, threshold', () => { cy.get(ALERT_GRID_CELL).contains(rule.name); }); - it('Preview results of keyword using "host.name"', () => { + it.skip('Preview results of keyword using "host.name"', () => { rule.index = [...rule.index, '.siem-signals*']; createCustomRuleActivated(getNewRule()); @@ -188,7 +188,7 @@ describe('Detection rules, threshold', () => { cy.get(PREVIEW_HEADER_SUBTITLE).should('have.text', '3 unique hits'); }); - it('Preview results of "ip" using "source.ip"', () => { + it.skip('Preview results of "ip" using "source.ip"', () => { const previewRule: ThresholdRule = { ...rule, thresholdField: 'source.ip', diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts new file mode 100644 index 0000000000000..bb57a8973c8e6 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loginAndWaitForPage } from '../../tasks/login'; + +import { HOSTS_URL } from '../../urls/navigation'; +import { cleanKibana } from '../../tasks/common'; +import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; +import { TABLE_CELL } from '../../screens/alerts_details'; +import { kqlSearch } from '../../tasks/security_header'; + +describe('All hosts table', () => { + before(() => { + cleanKibana(); + esArchiverLoad('risky_hosts'); + }); + + after(() => { + esArchiverUnload('risky_hosts'); + }); + + it('it renders risk column', () => { + loginAndWaitForPage(HOSTS_URL); + kqlSearch('host.name: "siem-kibana" {enter}'); + + cy.get('[data-test-subj="tableHeaderCell_node.risk_4"]').should('exist'); + cy.get(`${TABLE_CELL} .euiTableCellContent`).eq(4).should('have.text', 'Low'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts index 4f282e1e69d5c..602a9118128b5 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts @@ -8,13 +8,8 @@ import { loginAndWaitForPage } from '../../tasks/login'; import { HOSTS_URL } from '../../urls/navigation'; -import { cleanKibana } from '../../tasks/common'; describe('RiskyHosts KPI', () => { - before(() => { - cleanKibana(); - }); - it('it renders', () => { loginAndWaitForPage(HOSTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index aadaa5dfa0d88..a3e5e8af3f598 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -104,9 +104,9 @@ export const DEFINE_INDEX_INPUT = export const EQL_TYPE = '[data-test-subj="eqlRuleType"]'; -export const EQL_QUERY_INPUT = '[data-test-subj="eqlQueryBarTextInput"]'; +export const PREVIEW_HISTOGRAM = '[data-test-subj="preview-histogram-panel"]'; -export const EQL_QUERY_PREVIEW_HISTOGRAM = '[data-test-subj="queryPreviewEqlHistogram"]'; +export const EQL_QUERY_INPUT = '[data-test-subj="eqlQueryBarTextInput"]'; export const EQL_QUERY_VALIDATION_SPINNER = '[data-test-subj="eql-validation-loading"]'; @@ -170,7 +170,7 @@ export const RISK_OVERRIDE = export const RULES_CREATION_FORM = '[data-test-subj="stepDefineRule"]'; -export const RULES_CREATION_PREVIEW = '[data-test-subj="ruleCreationQueryPreview"]'; +export const RULES_CREATION_PREVIEW = '[data-test-subj="rule-preview"]'; export const RULE_DESCRIPTION_INPUT = '[data-test-subj="detectionEngineStepAboutRuleDescription"] [data-test-subj="input"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 68449363b8643..538f95c3c0a80 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -33,7 +33,6 @@ import { DEFAULT_RISK_SCORE_INPUT, DEFINE_CONTINUE_BUTTON, EQL_QUERY_INPUT, - EQL_QUERY_PREVIEW_HISTOGRAM, EQL_QUERY_VALIDATION_SPINNER, EQL_TYPE, FALSE_POSITIVES_INPUT, @@ -92,6 +91,7 @@ import { EMAIL_CONNECTOR_USER_INPUT, EMAIL_CONNECTOR_PASSWORD_INPUT, EMAIL_CONNECTOR_SERVICE_SELECTOR, + PREVIEW_HISTOGRAM, } from '../screens/create_new_rule'; import { TOAST_ERROR } from '../screens/shared'; import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; @@ -324,12 +324,12 @@ export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { .find(QUERY_PREVIEW_BUTTON) .should('not.be.disabled') .click({ force: true }); - cy.get(EQL_QUERY_PREVIEW_HISTOGRAM) + cy.get(PREVIEW_HISTOGRAM) .invoke('text') .then((text) => { if (text !== 'Hits') { cy.get(RULES_CREATION_PREVIEW).find(QUERY_PREVIEW_BUTTON).click({ force: true }); - cy.get(EQL_QUERY_PREVIEW_HISTOGRAM).should('contain.text', 'Hits'); + cy.get(PREVIEW_HISTOGRAM).should('contain.text', 'Hits'); } }); cy.get(TOAST_ERROR).should('not.exist'); diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/custom_query_rule.spec.ts similarity index 80% rename from x-pack/plugins/security_solution/cypress/upgrade_integration/custom_query_rule.spec.ts rename to x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/custom_query_rule.spec.ts index e4464ae43dd62..efc0d290ac728 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/custom_query_rule.spec.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import semver from 'semver'; import { DESTINATION_IP, HOST_NAME, @@ -14,8 +15,8 @@ import { SEVERITY, SOURCE_IP, USER_NAME, -} from '../screens/alerts'; -import { SERVER_SIDE_EVENT_COUNT } from '../screens/alerts_detection_rules'; +} from '../../../screens/alerts'; +import { SERVER_SIDE_EVENT_COUNT } from '../../../screens/alerts_detection_rules'; import { ADDITIONAL_LOOK_BACK_DETAILS, ABOUT_DETAILS, @@ -31,13 +32,16 @@ import { SCHEDULE_DETAILS, SEVERITY_DETAILS, TIMELINE_TEMPLATE_DETAILS, -} from '../screens/rule_details'; +} from '../../../screens/rule_details'; -import { waitForPageToBeLoaded } from '../tasks/common'; -import { waitForRulesTableToBeLoaded, goToTheRuleDetailsOf } from '../tasks/alerts_detection_rules'; -import { loginAndWaitForPage } from '../tasks/login'; +import { waitForPageToBeLoaded } from '../../../tasks/common'; +import { + waitForRulesTableToBeLoaded, + goToTheRuleDetailsOf, +} from '../../../tasks/alerts_detection_rules'; +import { loginAndWaitForPage } from '../../../tasks/login'; -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../urls/navigation'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; const EXPECTED_NUMBER_OF_ALERTS = '1'; @@ -63,8 +67,8 @@ const rule = { severity: 'Low', riskScore: '7', timelineTemplate: 'none', - runsEvery: '10s', - lookBack: '179999990s', + runsEvery: '24h', + lookBack: '49976h', timeline: 'None', }; @@ -100,10 +104,16 @@ describe('After an upgrade, the custom query rule', () => { }); it('Displays the alert details at the tgrid', () => { + let expectedReason; + if (semver.gt(Cypress.env('ORIGINAL_VERSION'), '7.15.0')) { + expectedReason = alert.reason; + } else { + expectedReason = '-'; + } cy.get(RULE_NAME).should('have.text', alert.rule); cy.get(SEVERITY).should('have.text', alert.severity); cy.get(RISK_SCORE).should('have.text', alert.riskScore); - cy.get(REASON).should('have.text', alert.reason).type('{rightarrow}'); + cy.get(REASON).should('have.text', expectedReason).type('{rightarrow}'); cy.get(HOST_NAME).should('have.text', alert.hostName); cy.get(USER_NAME).should('have.text', alert.username); cy.get(PROCESS_NAME).should('have.text', alert.processName); diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts similarity index 80% rename from x-pack/plugins/security_solution/cypress/upgrade_integration/threshold_rule.spec.ts rename to x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts index b6dbcd0e3232c..16949c9b34c63 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/threshold_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { HOST_NAME, REASON, RISK_SCORE, RULE_NAME, SEVERITY } from '../screens/alerts'; -import { SERVER_SIDE_EVENT_COUNT } from '../screens/alerts_detection_rules'; +import semver from 'semver'; +import { HOST_NAME, REASON, RISK_SCORE, RULE_NAME, SEVERITY } from '../../../screens/alerts'; +import { SERVER_SIDE_EVENT_COUNT } from '../../../screens/alerts_detection_rules'; import { ADDITIONAL_LOOK_BACK_DETAILS, ABOUT_DETAILS, @@ -23,14 +23,17 @@ import { SEVERITY_DETAILS, THRESHOLD_DETAILS, TIMELINE_TEMPLATE_DETAILS, -} from '../screens/rule_details'; +} from '../../../screens/rule_details'; -import { expandFirstAlert } from '../tasks/alerts'; -import { waitForPageToBeLoaded } from '../tasks/common'; -import { waitForRulesTableToBeLoaded, goToRuleDetails } from '../tasks/alerts_detection_rules'; -import { loginAndWaitForPage } from '../tasks/login'; +import { expandFirstAlert } from '../../../tasks/alerts'; +import { waitForPageToBeLoaded } from '../../../tasks/common'; +import { + goToTheRuleDetailsOf, + waitForRulesTableToBeLoaded, +} from '../../../tasks/alerts_detection_rules'; +import { loginAndWaitForPage } from '../../../tasks/login'; -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../urls/navigation'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; import { OVERVIEW_HOST_NAME, OVERVIEW_RISK_SCORE, @@ -40,7 +43,7 @@ import { OVERVIEW_THRESHOLD_COUNT, OVERVIEW_THRESHOLD_VALUE, SUMMARY_VIEW, -} from '../screens/alerts_details'; +} from '../../../screens/alerts_details'; const EXPECTED_NUMBER_OF_ALERTS = '1'; @@ -61,8 +64,8 @@ const rule = { severity: 'Medium', riskScore: '17', timelineTemplate: 'none', - runsEvery: '60s', - lookBack: '2999999m', + runsEvery: '24h', + lookBack: '49976h', timeline: 'None', thresholdField: 'host.name', threholdValue: '1', @@ -72,7 +75,7 @@ describe('After an upgrade, the threshold rule', () => { before(() => { loginAndWaitForPage(DETECTIONS_RULE_MANAGEMENT_URL); waitForRulesTableToBeLoaded(); - goToRuleDetails(); + goToTheRuleDetailsOf(rule.name); waitForPageToBeLoaded(); }); @@ -104,10 +107,16 @@ describe('After an upgrade, the threshold rule', () => { }); it('Displays the alert details in the TGrid', () => { + let expectedReason; + if (semver.gt(Cypress.env('ORIGINAL_VERSION'), '7.15.0')) { + expectedReason = alert.reason; + } else { + expectedReason = '-'; + } cy.get(RULE_NAME).should('have.text', alert.rule); cy.get(SEVERITY).should('have.text', alert.severity); cy.get(RISK_SCORE).should('have.text', alert.riskScore); - cy.get(REASON).should('have.text', alert.reason); + cy.get(REASON).should('have.text', expectedReason); cy.get(HOST_NAME).should('have.text', alert.hostName); }); diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/import_case.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts similarity index 90% rename from x-pack/plugins/security_solution/cypress/upgrade_integration/import_case.spec.ts rename to x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts index eb72dea9be7e8..e97cebeff00b5 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/import_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts @@ -15,7 +15,7 @@ import { ALL_CASES_OPEN_CASES_STATS, ALL_CASES_REPORTER, ALL_CASES_IN_PROGRESS_STATUS, -} from '../screens/all_cases'; +} from '../../../screens/all_cases'; import { CASES_TAGS, CASE_CONNECTOR, @@ -25,16 +25,19 @@ import { CASE_IN_PROGRESS_STATUS, CASE_SWITCH, CASE_USER_ACTION, -} from '../screens/case_details'; -import { CASES_PAGE } from '../screens/kibana_navigation'; +} from '../../../screens/case_details'; +import { CASES_PAGE } from '../../../screens/kibana_navigation'; -import { goToCaseDetails } from '../tasks/all_cases'; -import { deleteCase } from '../tasks/case_details'; -import { navigateFromKibanaCollapsibleTo, openKibanaNavigation } from '../tasks/kibana_navigation'; -import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; -import { importCase } from '../tasks/saved_objects'; +import { goToCaseDetails } from '../../../tasks/all_cases'; +import { deleteCase } from '../../../tasks/case_details'; +import { + navigateFromKibanaCollapsibleTo, + openKibanaNavigation, +} from '../../../tasks/kibana_navigation'; +import { loginAndWaitForPageWithoutDateRange } from '../../../tasks/login'; +import { importCase } from '../../../tasks/saved_objects'; -import { KIBANA_SAVED_OBJECTS } from '../urls/navigation'; +import { KIBANA_SAVED_OBJECTS } from '../../../urls/navigation'; const CASE_NDJSON = '7_16_case.ndjson'; const importedCase = { diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/import_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/timeline/import_timeline.spec.ts similarity index 93% rename from x-pack/plugins/security_solution/cypress/upgrade_integration/import_timeline.spec.ts rename to x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/timeline/import_timeline.spec.ts index f3b3f14e9c260..253a1c9c59b0b 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/import_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/timeline/import_timeline.spec.ts @@ -28,7 +28,7 @@ import { TIMELINE_QUERY, TIMELINE_TITLE, USER_KPI, -} from '../screens/timeline'; +} from '../../../screens/timeline'; import { NOTE, TIMELINES_USERNAME, @@ -36,19 +36,19 @@ import { TIMELINES_DESCRIPTION, TIMELINES_NOTES_COUNT, TIMELINES_PINNED_EVENT_COUNT, -} from '../screens/timelines'; +} from '../../../screens/timelines'; -import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; +import { loginAndWaitForPageWithoutDateRange } from '../../../tasks/login'; import { closeTimeline, deleteTimeline, goToCorrelationTab, goToNotesTab, goToPinnedTab, -} from '../tasks/timeline'; -import { expandNotes, importTimeline, openTimeline } from '../tasks/timelines'; +} from '../../../tasks/timeline'; +import { expandNotes, importTimeline, openTimeline } from '../../../tasks/timelines'; -import { TIMELINES_URL } from '../urls/navigation'; +import { TIMELINES_URL } from '../../../urls/navigation'; const timeline = '7_15_timeline.ndjson'; const username = 'elastic'; @@ -64,7 +64,6 @@ const timelineDetails = { }; const detectionAlert = { - timestamp: 'Nov 17, 2021 @ 09:36:25.499', message: '—', eventCategory: 'file', eventAction: 'initial_scan', @@ -149,7 +148,6 @@ describe('Import timeline after upgrade', () => { cy.get(NOTES_TAB_BUTTON).should('have.text', timelineDetails.notesTab); cy.get(PINNED_TAB_BUTTON).should('have.text', timelineDetails.pinnedTab); - cy.get(QUERY_EVENT_TABLE_CELL).eq(0).should('contain', detectionAlert.timestamp); cy.get(QUERY_EVENT_TABLE_CELL).eq(1).should('contain', detectionAlert.message); cy.get(QUERY_EVENT_TABLE_CELL).eq(2).should('contain', detectionAlert.eventCategory); cy.get(QUERY_EVENT_TABLE_CELL).eq(3).should('contain', detectionAlert.eventAction); @@ -196,7 +194,6 @@ describe('Import timeline after upgrade', () => { it('Displays the correct timeline details inside the pinned tab', () => { goToPinnedTab(); - cy.get(PINNED_EVENT_TABLE_CELL).eq(0).should('contain', detectionAlert.timestamp); cy.get(PINNED_EVENT_TABLE_CELL).eq(1).should('contain', detectionAlert.message); cy.get(PINNED_EVENT_TABLE_CELL).eq(2).should('contain', detectionAlert.eventCategory); cy.get(PINNED_EVENT_TABLE_CELL).eq(3).should('contain', detectionAlert.eventAction); diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 371ac66004f48..821550f21919a 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -19,6 +19,7 @@ "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "cypress:run:upgrade": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/upgrade_integration", + "cypress:run:upgrade:old": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/upgrade_integration --spec ./cypress/upgrade_integration/threat_hunting/**/*.spec.ts,./cypress/upgrade_integration/detections/**/custom_query_rule.spec.ts; status=$?; yarn junit:merge && exit $status", "junit:merge": "../../../node_modules/.bin/mochawesome-merge ../../../target/kibana-security-solution/cypress/results/mochawesome*.json > ../../../target/kibana-security-solution/cypress/results/output.json && ../../../node_modules/.bin/marge ../../../target/kibana-security-solution/cypress/results/output.json --reportDir ../../../target/kibana-security-solution/cypress/results && mkdir -p ../../../target/junit && cp ../../../target/kibana-security-solution/cypress/results/*.xml ../../../target/junit/", "test:generate": "node scripts/endpoint/resolver_generator" } diff --git a/x-pack/plugins/security_solution/public/common/components/auto_download/auto_download.tsx b/x-pack/plugins/security_solution/public/common/components/auto_download/auto_download.tsx index f7e863a648bc6..c9f36bfbe7681 100644 --- a/x-pack/plugins/security_solution/public/common/components/auto_download/auto_download.tsx +++ b/x-pack/plugins/security_solution/public/common/components/auto_download/auto_download.tsx @@ -23,7 +23,9 @@ export const AutoDownload: React.FC = ({ blob, name, onDownlo useEffect(() => { if (blob && anchorRef?.current) { + // @ts-expect-error if (typeof window.navigator.msSaveOrOpenBlob === 'function') { + // @ts-expect-error window.navigator.msSaveBlob(blob); } else { const objectURL = window.URL.createObjectURL(blob); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index 7e1e71a01642f..c397ac313c48c 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -30,6 +30,8 @@ const props = { browserFields: mockBrowserFields, eventId: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31', timelineId: 'detections-page', + title: '', + goToTable: jest.fn(), }; describe('AlertSummaryView', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx index b42a0425355cc..c30837dc6eca8 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiBasicTableColumn, EuiSpacer } from '@elastic/eui'; +import { EuiBasicTableColumn } from '@elastic/eui'; import React, { useMemo } from 'react'; import { BrowserFields } from '../../../../common/search_strategy/index_fields'; @@ -60,18 +60,21 @@ const AlertSummaryViewComponent: React.FC<{ eventId: string; isDraggable?: boolean; timelineId: string; - title?: string; -}> = ({ browserFields, data, eventId, isDraggable, timelineId, title }) => { + title: string; + goToTable: () => void; +}> = ({ browserFields, data, eventId, isDraggable, timelineId, title, goToTable }) => { const summaryRows = useMemo( () => getSummaryRows({ browserFields, data, eventId, isDraggable, timelineId }), [browserFields, data, eventId, isDraggable, timelineId] ); return ( - <> - - - + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index 37ca3b0b897a6..14910c77d198c 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -48,6 +48,8 @@ describe('EventDetails', () => { timelineId: 'test', eventView: EventsViewType.summaryView, hostRisk: { fields: [], loading: true }, + indexName: 'test', + handleOnEventClosed: jest.fn(), rawEventData, }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 0fe48d5a998ea..08f97ab7d1bc7 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -6,6 +6,7 @@ */ import { + EuiHorizontalRule, EuiTabbedContent, EuiTabbedContentTab, EuiSpacer, @@ -39,7 +40,9 @@ import { EnrichmentRangePicker } from './cti_details/enrichment_range_picker'; import { Reason } from './reason'; import { InvestigationGuideView } from './investigation_guide_view'; + import { HostRisk } from '../../containers/hosts_risk/use_hosts_risk_score'; +import { Overview } from './overview'; type EventViewTab = EuiTabbedContentTab; @@ -59,12 +62,14 @@ interface Props { browserFields: BrowserFields; data: TimelineEventsDetailsItem[]; id: string; + indexName: string; isAlert: boolean; isDraggable?: boolean; rawEventData: object | undefined; timelineTabType: TimelineTabs | 'flyout'; timelineId: string; hostRisk: HostRisk | null; + handleOnEventClosed: () => void; } export const Indent = styled.div` @@ -105,18 +110,21 @@ const EventDetailsComponent: React.FC = ({ browserFields, data, id, + indexName, isAlert, isDraggable, rawEventData, timelineId, timelineTabType, hostRisk, + handleOnEventClosed, }) => { const [selectedTabId, setSelectedTabId] = useState(EventsViewType.summaryView); const handleTabClick = useCallback( (tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EventViewId), - [setSelectedTabId] + [] ); + const goToTableTab = useCallback(() => setSelectedTabId(EventsViewType.tableView), []); const eventFields = useMemo(() => getEnrichmentFields(data), [data]); const existingEnrichments = useMemo( @@ -152,7 +160,19 @@ const EventDetailsComponent: React.FC = ({ name: i18n.OVERVIEW, content: ( <> + + + + = ({ timelineId, title: i18n.DUCOMENT_SUMMARY, }} + goToTable={goToTableTab} /> {(enrichmentCount > 0 || hostRisk) && ( @@ -188,8 +209,9 @@ const EventDetailsComponent: React.FC = ({ } : undefined, [ - isAlert, id, + indexName, + isAlert, data, browserFields, isDraggable, @@ -198,6 +220,8 @@ const EventDetailsComponent: React.FC = ({ allEnrichments, isEnrichmentsLoading, hostRisk, + goToTableTab, + handleOnEventClosed, ] ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 4af444c2ab8ad..0bf404fe51e39 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -5,53 +5,31 @@ * 2.0. */ -import { get, getOr, find, isEmpty } from 'lodash/fp'; +import { getOr, find, isEmpty } from 'lodash/fp'; import * as i18n from './translations'; import { BrowserFields } from '../../../../common/search_strategy/index_fields'; import { - ALERTS_HEADERS_RISK_SCORE, - ALERTS_HEADERS_RULE, - ALERTS_HEADERS_SEVERITY, ALERTS_HEADERS_THRESHOLD_CARDINALITY, ALERTS_HEADERS_THRESHOLD_COUNT, ALERTS_HEADERS_THRESHOLD_TERMS, ALERTS_HEADERS_RULE_NAME, - SIGNAL_STATUS, ALERTS_HEADERS_TARGET_IMPORT_HASH, - TIMESTAMP, ALERTS_HEADERS_RULE_DESCRIPTION, } from '../../../detections/components/alerts_table/translations'; import { AGENT_STATUS_FIELD_NAME, IP_FIELD_TYPE, - SIGNAL_RULE_NAME_FIELD_NAME, } from '../../../timelines/components/timeline/body/renderers/constants'; import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../../../network/components/ip'; -import { SummaryRow } from './helpers'; +import { getEnrichedFieldInfo, SummaryRow } from './helpers'; +import { EventSummaryField } from './types'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { isAlertFromEndpointEvent } from '../../utils/endpoint_alert_check'; import { EventCode } from '../../../../common/ecs/event'; -interface EventSummaryField { - id: string; - label?: string; - linkField?: string; - fieldType?: string; - overrideField?: string; -} - const defaultDisplayFields: EventSummaryField[] = [ - { id: 'kibana.alert.workflow_status', label: SIGNAL_STATUS }, - { id: '@timestamp', label: TIMESTAMP }, - { - id: SIGNAL_RULE_NAME_FIELD_NAME, - linkField: 'kibana.alert.rule.uuid', - label: ALERTS_HEADERS_RULE, - }, - { id: 'kibana.alert.rule.severity', label: ALERTS_HEADERS_SEVERITY }, - { id: 'kibana.alert.rule.risk_score', label: ALERTS_HEADERS_RISK_SCORE }, { id: 'host.name' }, { id: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS }, { id: 'user.name' }, @@ -151,50 +129,34 @@ export const getSummaryRows = ({ const tableFields = getEventFieldsToDisplay({ eventCategory, eventCode }); return data != null - ? tableFields.reduce((acc, item) => { - const initialDescription = { - contextId: timelineId, - eventId, - isDraggable, - value: null, - fieldType: 'string', - linkValue: undefined, - timelineId, - }; - const field = data.find((d) => d.field === item.id); - if (!field || isEmpty(field?.values)) { + ? tableFields.reduce((acc, field) => { + const item = data.find((d) => d.field === field.id); + if (!item || isEmpty(item?.values)) { return acc; } const linkValueField = - item.linkField != null && data.find((d) => d.field === item.linkField); - const linkValue = getOr(null, 'originalValue.0', linkValueField); - const value = getOr(null, 'originalValue.0', field); - const category = field.category ?? ''; - const fieldName = field.field ?? ''; - - const browserField = get([category, 'fields', fieldName], browserFields); + field.linkField != null && data.find((d) => d.field === field.linkField); const description = { - ...initialDescription, - data: { - field: field.field, - format: browserField?.format ?? '', - type: browserField?.type ?? '', - isObjectArray: field.isObjectArray, - ...(item.overrideField ? { field: item.overrideField } : {}), - }, - values: field.values, - linkValue: linkValue ?? undefined, - fieldFromBrowserField: browserField, + ...getEnrichedFieldInfo({ + item, + linkValueField: linkValueField || undefined, + contextId: timelineId, + timelineId, + browserFields, + eventId, + field, + }), + isDraggable, }; - if (item.id === 'agent.id' && !isAlertFromEndpointEvent({ data })) { + if (field.id === 'agent.id' && !isAlertFromEndpointEvent({ data })) { return acc; } - if (item.id === 'kibana.alert.threshold_result.terms') { + if (field.id === 'kibana.alert.threshold_result.terms') { try { - const terms = getOr(null, 'originalValue', field); + const terms = getOr(null, 'originalValue', item); const parsedValue = terms.map((term: string) => JSON.parse(term)); const thresholdTerms = (parsedValue ?? []).map( (entry: { field: string; value: string }) => { @@ -213,8 +175,9 @@ export const getSummaryRows = ({ } } - if (item.id === 'kibana.alert.threshold_result.cardinality') { + if (field.id === 'kibana.alert.threshold_result.cardinality') { try { + const value = getOr(null, 'originalValue.0', field); const parsedValue = JSON.parse(value); return [ ...acc, @@ -234,7 +197,7 @@ export const getSummaryRows = ({ return [ ...acc, { - title: item.label ?? item.id, + title: field.label ?? field.id, description, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx index 648bc96b5c9e7..dcca42f2a1df7 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx @@ -22,7 +22,8 @@ import { DEFAULT_DATE_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_MIN_WIDTH, } from '../../../timelines/components/timeline/body/constants'; -import { FieldsData } from './types'; +import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; +import type { EnrichedFieldInfo, EventSummaryField } from './types'; import * as i18n from './translations'; import { ColumnHeaderOptions } from '../../../../common/types'; @@ -56,14 +57,8 @@ export interface Item { export interface AlertSummaryRow { title: string; - description: { - data: FieldsData; - eventId: string; + description: EnrichedFieldInfo & { isDraggable?: boolean; - fieldFromBrowserField?: BrowserField; - linkValue: string | undefined; - timelineId: string; - values: string[] | null | undefined; }; } @@ -232,3 +227,47 @@ export const getSummaryColumns = ( }, ]; }; + +export function getEnrichedFieldInfo({ + browserFields, + contextId, + eventId, + field, + item, + linkValueField, + timelineId, +}: { + browserFields: BrowserFields; + contextId: string; + item: TimelineEventsDetailsItem; + eventId: string; + field?: EventSummaryField; + timelineId: string; + linkValueField?: TimelineEventsDetailsItem; +}): EnrichedFieldInfo { + const fieldInfo = { + contextId, + eventId, + fieldType: 'string', + linkValue: undefined, + timelineId, + }; + const linkValue = getOr(null, 'originalValue.0', linkValueField); + const category = item.category ?? ''; + const fieldName = item.field ?? ''; + + const browserField = get([category, 'fields', fieldName], browserFields); + const overrideField = field?.overrideField; + return { + ...fieldInfo, + data: { + field: overrideField ?? fieldName, + format: browserField?.format ?? '', + type: browserField?.type ?? '', + isObjectArray: item.isObjectArray, + }, + values: item.values, + linkValue: linkValue ?? undefined, + fieldFromBrowserField: browserField, + }; +} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/overview/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..4e62766fc1477 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/__snapshots__/index.test.tsx.snap @@ -0,0 +1,311 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = ` + + .c6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +.c6:focus-within .timelines__hoverActionButton, +.c6:focus-within .securitySolution__hoverActionButton { + opacity: 1; +} + +.c6:hover .timelines__hoverActionButton, +.c6:hover .securitySolution__hoverActionButton { + opacity: 1; +} + +.c6 .timelines__hoverActionButton, +.c6 .securitySolution__hoverActionButton { + opacity: 0; +} + +.c6 .timelines__hoverActionButton:focus, +.c6 .securitySolution__hoverActionButton:focus { + opacity: 1; +} + +.c3 { + text-transform: capitalize; +} + +.c5 { + width: 0; + -webkit-transform: translate(6px); + -ms-transform: translate(6px); + transform: translate(6px); + -webkit-transition: -webkit-transform 50ms ease-in-out; + -webkit-transition: transform 50ms ease-in-out; + transition: transform 50ms ease-in-out; + margin-left: 8px; +} + +.c1.c1.c1 { + background-color: #25262e; + padding: 8px; + height: 78px; +} + +.c1 .hoverActions-active .timelines__hoverActionButton, +.c1 .hoverActions-active .securitySolution__hoverActionButton { + opacity: 1; +} + +.c1:hover .timelines__hoverActionButton, +.c1:hover .securitySolution__hoverActionButton { + opacity: 1; +} + +.c1:hover .c4 { + width: auto; + -webkit-transform: translate(0); + -ms-transform: translate(0); + transform: translate(0); +} + +.c2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.c0 { + -webkit-box-flex: 0; + -webkit-flex-grow: 0; + -ms-flex-positive: 0; + flex-grow: 0; +} + +
+
+
+
+
+ Status +
+
+
+
+
+
+ +
+
+
+
+
+
+

+ You are in a dialog, containing options for field kibana.alert.workflow_status. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+
+
+
+ Risk Score +
+
+
+
+ 47 +
+
+
+
+

+ You are in a dialog, containing options for field kibana.alert.rule.risk_score. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+
+
+
+
+
+
+ Rule +
+
+
+
+ +
+
+
+
+

+ You are in a dialog, containing options for field kibana.alert.rule.name. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+
+
+ +`; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/overview/index.test.tsx new file mode 100644 index 0000000000000..50da80f7b1304 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/index.test.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { Overview } from './'; +import { TestProviders } from '../../../../common/mock'; + +jest.mock('../../../lib/kibana'); +jest.mock('../../utils', () => ({ + useThrottledResizeObserver: () => ({ width: 400 }), // force row-chunking +})); + +describe('Event Details Overview Cards', () => { + it('renders all cards', () => { + const { getByText } = render( + + + + ); + + getByText('Status'); + getByText('Severity'); + getByText('Risk Score'); + getByText('Rule'); + }); + + it('renders all cards it has data for', () => { + const { getByText, queryByText } = render( + + + + ); + + getByText('Status'); + getByText('Risk Score'); + getByText('Rule'); + + expect(queryByText('Severity')).not.toBeInTheDocument(); + }); + + it('renders rows and spacers correctly', () => { + const { asFragment } = render( + + + + ); + + expect(asFragment()).toMatchSnapshot(); + }); +}); + +const props = { + handleOnEventClosed: jest.fn(), + contextId: 'detections-page', + eventId: 'testId', + indexName: 'testIndex', + timelineId: 'page', + data: [ + { + category: 'kibana', + field: 'kibana.alert.rule.risk_score', + values: ['47'], + originalValue: ['47'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.uuid', + values: ['d9f537c0-47b2-11ec-9517-c1c68c44dec0'], + originalValue: ['d9f537c0-47b2-11ec-9517-c1c68c44dec0'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.workflow_status', + values: ['open'], + originalValue: ['open'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.name', + values: ['More than one event with user name'], + originalValue: ['More than one event with user name'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.severity', + values: ['medium'], + originalValue: ['medium'], + isObjectArray: false, + }, + ], + browserFields: { + kibana: { + fields: { + 'kibana.alert.rule.severity': { + category: 'kibana', + count: 0, + name: 'kibana.alert.rule.severity', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: 'string', + shortDotsEnable: false, + isMapped: true, + indexes: ['apm-*-transaction*'], + }, + 'kibana.alert.rule.risk_score': { + category: 'kibana', + count: 0, + name: 'kibana.alert.rule.risk_score', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: 'number', + shortDotsEnable: false, + isMapped: true, + indexes: ['apm-*-transaction*'], + }, + 'kibana.alert.rule.uuid': { + category: 'kibana', + count: 0, + name: 'kibana.alert.rule.uuid', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: 'string', + shortDotsEnable: false, + isMapped: true, + indexes: ['apm-*-transaction*'], + }, + 'kibana.alert.workflow_status': { + category: 'kibana', + count: 0, + name: 'kibana.alert.workflow_status', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: 'string', + shortDotsEnable: false, + isMapped: true, + indexes: ['apm-*-transaction*'], + }, + 'kibana.alert.rule.name': { + category: 'kibana', + count: 0, + name: 'kibana.alert.rule.name', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: 'string', + shortDotsEnable: false, + isMapped: true, + indexes: ['apm-*-transaction*'], + }, + }, + }, + }, +}; + +const dataWithoutSeverity = props.data.filter( + (data) => data.field !== 'kibana.alert.rule.severity' +); + +const fieldsWithoutSeverity = { + 'kibana.alert.rule.risk_score': props.browserFields.kibana.fields['kibana.alert.rule.risk_score'], + 'kibana.alert.rule.uuid': props.browserFields.kibana.fields['kibana.alert.rule.uuid'], + 'kibana.alert.workflow_status': props.browserFields.kibana.fields['kibana.alert.workflow_status'], + 'kibana.alert.rule.name': props.browserFields.kibana.fields['kibana.alert.rule.name'], +}; + +const propsWithoutSeverity = { + ...props, + browserFields: { kibana: { fields: fieldsWithoutSeverity } }, + data: dataWithoutSeverity, +}; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/index.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/overview/index.tsx new file mode 100644 index 0000000000000..70a8ec7ad0d22 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/index.tsx @@ -0,0 +1,210 @@ +/* + * Copyright 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, EuiSpacer } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { chunk, find } from 'lodash/fp'; +import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; + +import type { BrowserFields } from '../../../containers/source'; +import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; +import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; +import type { EnrichedFieldInfo, EnrichedFieldInfoWithValues } from '../types'; +import { getEnrichedFieldInfo } from '../helpers'; +import { + ALERTS_HEADERS_RISK_SCORE, + ALERTS_HEADERS_RULE, + ALERTS_HEADERS_SEVERITY, + SIGNAL_STATUS, +} from '../../../../detections/components/alerts_table/translations'; +import { + SIGNAL_RULE_NAME_FIELD_NAME, + SIGNAL_STATUS_FIELD_NAME, +} from '../../../../timelines/components/timeline/body/renderers/constants'; +import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field'; +import { OverviewCardWithActions } from '../overview/overview_card'; +import { StatusPopoverButton } from '../overview/status_popover_button'; +import { SeverityBadge } from '../../../../../public/detections/components/rules/severity_badge'; +import { useThrottledResizeObserver } from '../../utils'; +import { isNotNull } from '../../../../../public/timelines/store/timeline/helpers'; + +export const NotGrowingFlexGroup = euiStyled(EuiFlexGroup)` + flex-grow: 0; +`; + +interface Props { + browserFields: BrowserFields; + contextId: string; + data: TimelineEventsDetailsItem[]; + eventId: string; + handleOnEventClosed: () => void; + indexName: string; + timelineId: string; +} + +export const Overview = React.memo( + ({ browserFields, contextId, data, eventId, handleOnEventClosed, indexName, timelineId }) => { + const statusData = useMemo(() => { + const item = find({ field: SIGNAL_STATUS_FIELD_NAME, category: 'kibana' }, data); + return ( + item && + getEnrichedFieldInfo({ + eventId, + contextId, + timelineId, + browserFields, + item, + }) + ); + }, [browserFields, contextId, data, eventId, timelineId]); + + const severityData = useMemo(() => { + const item = find({ field: 'kibana.alert.rule.severity', category: 'kibana' }, data); + return ( + item && + getEnrichedFieldInfo({ + eventId, + contextId, + timelineId, + browserFields, + item, + }) + ); + }, [browserFields, contextId, data, eventId, timelineId]); + + const riskScoreData = useMemo(() => { + const item = find({ field: 'kibana.alert.rule.risk_score', category: 'kibana' }, data); + return ( + item && + getEnrichedFieldInfo({ + eventId, + contextId, + timelineId, + browserFields, + item, + }) + ); + }, [browserFields, contextId, data, eventId, timelineId]); + + const ruleNameData = useMemo(() => { + const item = find({ field: SIGNAL_RULE_NAME_FIELD_NAME, category: 'kibana' }, data); + const linkValueField = find({ field: 'kibana.alert.rule.uuid', category: 'kibana' }, data); + return ( + item && + getEnrichedFieldInfo({ + eventId, + contextId, + timelineId, + browserFields, + item, + linkValueField, + }) + ); + }, [browserFields, contextId, data, eventId, timelineId]); + + const signalCard = hasData(statusData) ? ( + + + + + + ) : null; + + const severityCard = hasData(severityData) ? ( + + + + + + ) : null; + + const riskScoreCard = hasData(riskScoreData) ? ( + + + {riskScoreData.values[0]} + + + ) : null; + + const ruleNameCard = hasData(ruleNameData) ? ( + + + + + + ) : null; + + const { width, ref } = useThrottledResizeObserver(); + + // 675px is the container width at which none of the cards, when hovered, + // creates a visual overflow in a single row setup + const showAsSingleRow = width === 0 || width >= 675; + + // Only render cards with content + const cards = [signalCard, severityCard, riskScoreCard, ruleNameCard].filter(isNotNull); + + // If there is enough space, render a single row. + // Otherwise, render two rows with each two cards. + const content = showAsSingleRow ? ( + {cards} + ) : ( + <> + {chunk(2, cards).map((elements, index, { length }) => { + // Add a spacer between rows but not after the last row + const addSpacer = index < length - 1; + return ( + <> + {elements} + {addSpacer && } + + ); + })} + + ); + + return
{content}
; + } +); + +function hasData(fieldInfo?: EnrichedFieldInfo): fieldInfo is EnrichedFieldInfoWithValues { + return !!fieldInfo && Array.isArray(fieldInfo.values); +} + +Overview.displayName = 'Overview'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/overview_card.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/overview/overview_card.test.tsx new file mode 100644 index 0000000000000..8ed3dc7e36165 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/overview_card.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { OverviewCardWithActions } from './overview_card'; +import { TestProviders } from '../../../../common/mock'; +import { SeverityBadge } from '../../../../../public/detections/components/rules/severity_badge'; + +const props = { + title: 'Severity', + contextId: 'timeline-case', + enrichedFieldInfo: { + contextId: 'timeline-case', + eventId: 'testid', + fieldType: 'string', + timelineId: 'timeline-case', + data: { + field: 'kibana.alert.rule.severity', + format: 'string', + type: 'string', + isObjectArray: false, + }, + values: ['medium'], + fieldFromBrowserField: { + category: 'kibana', + count: 0, + name: 'kibana.alert.rule.severity', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: 'string', + shortDotsEnable: false, + isMapped: true, + indexes: ['apm-*-transaction*'], + description: '', + example: '', + fields: {}, + }, + }, +}; + +jest.mock('../../../lib/kibana'); + +describe('OverviewCardWithActions', () => { + test('it renders correctly', () => { + const { getByText } = render( + + + + + + ); + + // Headline + getByText('Severity'); + + // Content + getByText('Medium'); + + // Hover actions + getByText('Add To Timeline'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/overview_card.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/overview/overview_card.tsx new file mode 100644 index 0000000000000..4d3dae271f5c9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/overview_card.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; + +import { ActionCell } from '../table/action_cell'; +import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; +import { EnrichedFieldInfo } from '../types'; + +const ActionWrapper = euiStyled.div` + width: 0; + transform: translate(6px); + transition: transform 50ms ease-in-out; + margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; +`; + +const OverviewPanel = euiStyled(EuiPanel)` + &&& { + background-color: ${({ theme }) => theme.eui.euiColorLightestShade}; + padding: ${({ theme }) => theme.eui.paddingSizes.s}; + height: 78px; + } + + & { + .hoverActions-active { + .timelines__hoverActionButton, + .securitySolution__hoverActionButton { + opacity: 1; + } + } + + &:hover { + .timelines__hoverActionButton, + .securitySolution__hoverActionButton { + opacity: 1; + } + + ${ActionWrapper} { + width: auto; + transform: translate(0); + } + } + } +`; + +interface OverviewCardProps { + title: string; +} + +export const OverviewCard: React.FC = ({ title, children }) => ( + + {title} + + {children} + +); + +OverviewCard.displayName = 'OverviewCard'; + +const ClampedContent = euiStyled.div` + /* Clamp text content to 2 lines */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +`; + +ClampedContent.displayName = 'ClampedContent'; + +type OverviewCardWithActionsProps = OverviewCardProps & { + contextId: string; + enrichedFieldInfo: EnrichedFieldInfo; +}; + +export const OverviewCardWithActions: React.FC = ({ + title, + children, + contextId, + enrichedFieldInfo, +}) => { + return ( + + + {children} + + + + + + + ); +}; + +OverviewCardWithActions.displayName = 'OverviewCardWithActions'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.test.tsx new file mode 100644 index 0000000000000..3c3316618a72c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { StatusPopoverButton } from './status_popover_button'; +import { TestProviders } from '../../../../common/mock'; + +const props = { + eventId: 'testid', + contextId: 'detections-page', + enrichedFieldInfo: { + contextId: 'detections-page', + eventId: 'testid', + fieldType: 'string', + timelineId: 'detections-page', + data: { + field: 'kibana.alert.workflow_status', + format: 'string', + type: 'string', + isObjectArray: false, + }, + values: ['open'], + fieldFromBrowserField: { + category: 'kibana', + count: 0, + name: 'kibana.alert.workflow_status', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: 'string', + shortDotsEnable: false, + isMapped: true, + indexes: ['apm-*-transaction*'], + description: '', + example: '', + fields: {}, + }, + }, + indexName: '.internal.alerts-security.alerts-default-000001', + timelineId: 'detections-page', + handleOnEventClosed: jest.fn(), +}; + +jest.mock( + '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', + () => ({ + useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }), + }) +); + +describe('StatusPopoverButton', () => { + test('it renders the correct status', () => { + const { getByText } = render( + + + + ); + + getByText('open'); + }); + + test('it shows the correct options when clicked', () => { + const { getByText } = render( + + + + ); + + getByText('open').click(); + + getByText('Mark as acknowledged'); + getByText('Mark as closed'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.tsx new file mode 100644 index 0000000000000..0ffa1570e7c29 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 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 { EuiContextMenuPanel, EuiPopover, EuiPopoverTitle } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { useAlertsActions } from '../../../../detections/components/alerts_table/timeline_actions/use_alerts_actions'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { + CHANGE_ALERT_STATUS, + CLICK_TO_CHANGE_ALERT_STATUS, +} from '../../../../detections/components/alerts_table/translations'; +import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field'; +import type { EnrichedFieldInfoWithValues } from '../types'; + +interface StatusPopoverButtonProps { + eventId: string; + contextId: string; + enrichedFieldInfo: EnrichedFieldInfoWithValues; + indexName: string; + timelineId: string; + handleOnEventClosed: () => void; +} + +export const StatusPopoverButton = React.memo( + ({ eventId, contextId, enrichedFieldInfo, indexName, timelineId, handleOnEventClosed }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const closeAfterAction = useCallback(() => { + closePopover(); + handleOnEventClosed(); + }, [closePopover, handleOnEventClosed]); + + const { actionItems } = useAlertsActions({ + closePopover: closeAfterAction, + eventId, + timelineId, + indexName, + alertStatus: enrichedFieldInfo.values[0] as Status, + }); + + const button = useMemo( + () => ( + + ), + [contextId, eventId, enrichedFieldInfo, togglePopover] + ); + + return ( + + {CHANGE_ALERT_STATUS} + + + ); + } +); + +StatusPopoverButton.displayName = 'StatusPopoverButton'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/reason.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/reason.tsx index d06f4d3ea105b..88208dd1b9780 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/reason.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/reason.tsx @@ -5,18 +5,12 @@ * 2.0. */ -import { EuiTextColor, EuiFlexItem, EuiSpacer, EuiHorizontalRule, EuiTitle } from '@elastic/eui'; -import { ALERT_REASON, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; +import { EuiTextColor, EuiFlexItem } from '@elastic/eui'; +import { ALERT_REASON } from '@kbn/rule-data-utils'; import React, { useMemo } from 'react'; -import styled from 'styled-components'; -import { getRuleDetailsUrl, useFormatUrl } from '../link_to'; -import * as i18n from './translations'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; -import { LinkAnchor } from '../links'; -import { useKibana } from '../../lib/kibana'; -import { APP_UI_ID, SecurityPageName } from '../../../../common/constants'; import { EVENT_DETAILS_PLACEHOLDER } from '../../../timelines/components/side_panel/event_details/translations'; import { getFieldValue } from '../../../detections/components/host_isolation/helpers'; @@ -25,16 +19,7 @@ interface Props { eventId: string; } -export const Indent = styled.div` - padding: 0 8px; - word-break: break-word; - line-height: 1.7em; -`; - export const ReasonComponent: React.FC = ({ eventId, data }) => { - const { navigateToApp } = useKibana().services.application; - const { formatUrl } = useFormatUrl(SecurityPageName.rules); - const reason = useMemo(() => { const siemSignalsReason = getFieldValue( { category: 'signal', field: 'signal.alert.reason' }, @@ -44,47 +29,11 @@ export const ReasonComponent: React.FC = ({ eventId, data }) => { return aadReason.length > 0 ? aadReason : siemSignalsReason; }, [data]); - const ruleId = useMemo(() => { - const siemSignalsRuleId = getFieldValue({ category: 'signal', field: 'signal.rule.id' }, data); - const aadRuleId = getFieldValue({ category: 'kibana', field: ALERT_RULE_UUID }, data); - return aadRuleId.length > 0 ? aadRuleId : siemSignalsRuleId; - }, [data]); - if (!eventId) { return {EVENT_DETAILS_PLACEHOLDER}; } - return reason ? ( - - - -
{i18n.REASON}
-
- - - {reason} - - - - - void }) => { - ev.preventDefault(); - navigateToApp(APP_UI_ID, { - deepLinkId: SecurityPageName.rules, - path: getRuleDetailsUrl(ruleId), - }); - }} - href={formatUrl(getRuleDetailsUrl(ruleId))} - > - {i18n.VIEW_RULE_DETAIL_PAGE} - - - - -
- ) : null; + return reason ? {reason} : null; }; ReasonComponent.displayName = 'ReasonComponent'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx index cf8bf3ddb7474..a84d831524983 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx @@ -5,14 +5,24 @@ * 2.0. */ -import { EuiInMemoryTable, EuiBasicTableColumn, EuiTitle } from '@elastic/eui'; +import { + EuiInMemoryTable, + EuiBasicTableColumn, + EuiLink, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; import { SummaryRow } from './helpers'; +import { VIEW_ALL_DOCUMENT_FIELDS } from './translations'; export const Indent = styled.div` - padding: 0 4px; + padding: 0 12px; `; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -43,18 +53,27 @@ export const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` `; export const SummaryViewComponent: React.FC<{ - title?: string; + goToTable: () => void; + title: string; summaryColumns: Array>; summaryRows: SummaryRow[]; dataTestSubj?: string; -}> = ({ summaryColumns, summaryRows, dataTestSubj = 'summary-view', title }) => { +}> = ({ goToTable, summaryColumns, summaryRows, dataTestSubj = 'summary-view', title }) => { return ( - <> - {title && ( - -
{title}
-
- )} +
+ + + +
{title}
+
+
+ + + {VIEW_ALL_DOCUMENT_FIELDS} + + +
+ - +
); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx index 74d46cf3431dc..b49aafea92245 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx @@ -8,27 +8,22 @@ import React, { useCallback, useState, useContext } from 'react'; import { HoverActions } from '../../hover_actions'; import { useActionCellDataProvider } from './use_action_cell_data_provider'; -import { EventFieldsData, FieldsData } from '../types'; +import { EnrichedFieldInfo } from '../types'; import { ColumnHeaderOptions } from '../../../../../common/types/timeline'; -import { BrowserField } from '../../../containers/source'; import { TimelineContext } from '../../../../../../timelines/public'; -interface Props { +interface Props extends EnrichedFieldInfo { contextId: string; - data: FieldsData | EventFieldsData; + applyWidthAndPadding?: boolean; disabled?: boolean; - eventId: string; - fieldFromBrowserField?: BrowserField; getLinkValue?: (field: string) => string | null; - linkValue?: string | null | undefined; onFilterAdded?: () => void; - timelineId?: string; toggleColumn?: (column: ColumnHeaderOptions) => void; - values: string[] | null | undefined; } export const ActionCell: React.FC = React.memo( ({ + applyWidthAndPadding = true, contextId, data, eventId, @@ -68,6 +63,7 @@ export const ActionCell: React.FC = React.memo( return ( { let updateExceptionListItem: jest.SpyInstance>; let getQueryFilter: jest.SpyInstance>; let buildAlertStatusesFilter: jest.SpyInstance< - ReturnType - >; - let buildAlertsRuleIdFilter: jest.SpyInstance< - ReturnType + ReturnType >; + let buildAlertsFilter: jest.SpyInstance>; let addOrUpdateItemsArgs: Parameters; let render: () => RenderHookResult; const onError = jest.fn(); const onSuccess = jest.fn(); - const ruleId = 'rule-id'; + const ruleStaticId = 'rule-id'; const alertIdToClose = 'idToClose'; const bulkCloseIndex = ['.custom']; const itemsToAdd: CreateExceptionListItemSchema[] = [ @@ -128,14 +126,11 @@ describe('useAddOrUpdateException', () => { getQueryFilter = jest.spyOn(getQueryFilterHelper, 'getQueryFilter'); - buildAlertStatusesFilter = jest.spyOn( - buildFilterHelpers, - 'buildAlertStatusesFilterRuleRegistry' - ); + buildAlertStatusesFilter = jest.spyOn(buildFilterHelpers, 'buildAlertStatusesFilter'); - buildAlertsRuleIdFilter = jest.spyOn(buildFilterHelpers, 'buildAlertsRuleIdFilter'); + buildAlertsFilter = jest.spyOn(buildFilterHelpers, 'buildAlertsFilter'); - addOrUpdateItemsArgs = [ruleId, itemsToAddOrUpdate]; + addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate]; render = () => renderHook( () => @@ -262,7 +257,7 @@ describe('useAddOrUpdateException', () => { describe('when alertIdToClose is passed in', () => { beforeEach(() => { - addOrUpdateItemsArgs = [ruleId, itemsToAddOrUpdate, alertIdToClose]; + addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate, alertIdToClose]; }); it('should update the alert status', async () => { await act(async () => { @@ -317,7 +312,7 @@ describe('useAddOrUpdateException', () => { describe('when bulkCloseIndex is passed in', () => { beforeEach(() => { - addOrUpdateItemsArgs = [ruleId, itemsToAddOrUpdate, undefined, bulkCloseIndex]; + addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate, undefined, bulkCloseIndex]; }); it('should update the status of only alerts that are open', async () => { await act(async () => { @@ -351,8 +346,8 @@ describe('useAddOrUpdateException', () => { addOrUpdateItems(...addOrUpdateItemsArgs); } await waitForNextUpdate(); - expect(buildAlertsRuleIdFilter).toHaveBeenCalledTimes(1); - expect(buildAlertsRuleIdFilter.mock.calls[0][0]).toEqual(ruleId); + expect(buildAlertsFilter).toHaveBeenCalledTimes(1); + expect(buildAlertsFilter.mock.calls[0][0]).toEqual(ruleStaticId); }); }); it('should generate the query filter using exceptions', async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index 7cb8b643aa0e8..71c49f7c2daad 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -17,27 +17,25 @@ import { HttpStart } from '../../../../../../../src/core/public'; import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; import { - buildAlertsRuleIdFilter, + buildAlertsFilter, buildAlertStatusesFilter, - buildAlertStatusesFilterRuleRegistry, } from '../../../detections/components/alerts_table/default_config'; import { getQueryFilter } from '../../../../common/detection_engine/get_query_filter'; import { Index } from '../../../../common/detection_engine/schemas/common/schemas'; -import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from './helpers'; import { useKibana } from '../../lib/kibana'; /** * Adds exception items to the list. Also optionally closes alerts. * - * @param ruleId id of the rule where the exception updates will be applied + * @param ruleStaticId static id of the rule (rule.ruleId, not rule.id) where the exception updates will be applied * @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update * @param alertIdToClose - optional string representing alert to close * @param bulkCloseIndex - optional index used to create bulk close query * */ export type AddOrUpdateExceptionItemsFunc = ( - ruleId: string, + ruleStaticId: string, exceptionItemsToAddOrUpdate: Array, alertIdToClose?: string, bulkCloseIndex?: Index @@ -72,10 +70,10 @@ export const useAddOrUpdateException = ({ const addOrUpdateExceptionRef = useRef(null); const { addExceptionListItem, updateExceptionListItem } = useApi(services.http); const addOrUpdateException = useCallback( - async (ruleId, exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex) => { + async (ruleStaticId, exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex) => { if (addOrUpdateExceptionRef.current != null) { addOrUpdateExceptionRef.current( - ruleId, + ruleStaticId, exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex @@ -84,15 +82,13 @@ export const useAddOrUpdateException = ({ }, [] ); - // TODO: Once we are past experimental phase this code should be removed - const ruleRegistryEnabled = useIsExperimentalFeatureEnabled('ruleRegistryEnabled'); useEffect(() => { let isSubscribed = true; const abortCtrl = new AbortController(); const onUpdateExceptionItemsAndAlertStatus: AddOrUpdateExceptionItemsFunc = async ( - ruleId, + ruleStaticId, exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex @@ -131,15 +127,16 @@ export const useAddOrUpdateException = ({ } if (bulkCloseIndex != null) { - // TODO: Once we are past experimental phase this code should be removed - const alertStatusFilter = ruleRegistryEnabled - ? buildAlertStatusesFilterRuleRegistry(['open', 'acknowledged', 'in-progress']) - : buildAlertStatusesFilter(['open', 'acknowledged', 'in-progress']); + const alertStatusFilter = buildAlertStatusesFilter([ + 'open', + 'acknowledged', + 'in-progress', + ]); const filter = getQueryFilter( '', 'kuery', - [...buildAlertsRuleIdFilter(ruleId), ...alertStatusFilter], + [...buildAlertsFilter(ruleStaticId), ...alertStatusFilter], bulkCloseIndex, prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate), false @@ -185,14 +182,7 @@ export const useAddOrUpdateException = ({ isSubscribed = false; abortCtrl.abort(); }; - }, [ - addExceptionListItem, - http, - onSuccess, - onError, - ruleRegistryEnabled, - updateExceptionListItem, - ]); + }, [addExceptionListItem, http, onSuccess, onError, updateExceptionListItem]); return [{ isLoading }, addOrUpdateException]; }; diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx index 81ecec7bdc535..311284565ba14 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx @@ -37,8 +37,6 @@ const StyledHoverActionsContainer = styled.div<{ $hideTopN: boolean; $isActive: boolean; }>` - min-width: ${({ $hideTopN }) => `${$hideTopN ? '112px' : '138px'}`}; - padding: ${(props) => `0 ${props.theme.eui.paddingSizes.s}`}; display: flex; ${(props) => @@ -82,8 +80,14 @@ const StyledHoverActionsContainer = styled.div<{ : ''} `; +const StyledHoverActionsContainerWithPaddingsAndMinWidth = styled(StyledHoverActionsContainer)` + min-width: ${({ $hideTopN }) => `${$hideTopN ? '112px' : '138px'}`}; + padding: ${(props) => `0 ${props.theme.eui.paddingSizes.s}`}; +`; + interface Props { additionalContent?: React.ReactNode; + applyWidthAndPadding?: boolean; closeTopN?: () => void; closePopOver?: () => void; dataProvider?: DataProvider | DataProvider[]; @@ -128,6 +132,7 @@ export const HoverActions: React.FC = React.memo( dataType, draggableId, enableOverflowButton = false, + applyWidthAndPadding = true, field, goGetTimelineId, isObjectArray, @@ -227,6 +232,10 @@ export const HoverActions: React.FC = React.memo( values, }); + const Container = applyWidthAndPadding + ? StyledHoverActionsContainerWithPaddingsAndMinWidth + : StyledHoverActionsContainer; + return ( = React.memo( showTopN, })} > - = React.memo( {additionalContent != null && {additionalContent}} {enableOverflowButton && !isCaseView ? overflowActionItems : allActionItems} - + ); } diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index b67505a66be44..e5da55f740033 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -15,7 +15,7 @@ import { MatrixHistogramType } from '../../../../common/search_strategy/security import { UpdateDateRange } from '../charts/common'; import { GlobalTimeArgs } from '../../containers/use_global_time'; import { DocValueFields } from '../../../../common/search_strategy'; -import { Threshold } from '../../../detections/components/rules/query_preview'; +import { FieldValueThreshold } from '../../../detections/components/rules/threshold_input'; export type MatrixHistogramMappingTypes = Record< string, @@ -77,7 +77,7 @@ export interface MatrixHistogramQueryProps { stackByField: string; startDate: string; histogramType: MatrixHistogramType; - threshold?: Threshold; + threshold?: FieldValueThreshold; skip?: boolean; isPtrIncluded?: boolean; includeMissingData?: boolean; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx index 49a4f22ef0a75..b4db5f6798860 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx @@ -81,7 +81,6 @@ const AnomaliesHostTableComponent: React.FC = ({ type is not as specific as EUI's... columns={columns} - // @ts-expect-error ...which leads to `networks` not "matching" the columns items={hosts} pagination={pagination} sorting={sorting} diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx index 24fe1c06aef4c..51561c9bf0d63 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx @@ -71,7 +71,6 @@ const AnomaliesNetworkTableComponent: React.FC = ({ type is not as specific as EUI's... columns={columns} - // @ts-expect-error ...which leads to `networks` not "matching" the columns items={networks} pagination={pagination} sorting={sorting} diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx index 2ffb0670c4edc..6888efc40262b 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx @@ -6,12 +6,7 @@ */ import { - EuiButton, - EuiCallOut, - EuiCheckbox, EuiComboBox, - EuiFlexGroup, - EuiFlexItem, EuiForm, EuiOutsideClickDetector, EuiPopover, @@ -19,7 +14,7 @@ import { EuiSpacer, EuiSuperSelect, } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { ChangeEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import * as i18n from './translations'; @@ -27,12 +22,12 @@ import { sourcererActions, sourcererModel, sourcererSelectors } from '../../stor import { useDeepEqualSelector } from '../../hooks/use_selector'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { usePickIndexPatterns } from './use_pick_index_patterns'; -import { FormRow, PopoverContent, ResetButton, StyledButton, StyledFormRow } from './helpers'; +import { FormRow, PopoverContent, StyledButton, StyledFormRow } from './helpers'; import { TemporarySourcerer } from './temporary'; -import { UpdateDefaultDataViewModal } from './update_default_data_view_modal'; import { useSourcererDataView } from '../../containers/sourcerer'; import { useUpdateDataView } from './use_update_data_view'; import { Trigger } from './trigger'; +import { AlertsCheckbox, SaveButtons, SourcererCallout } from './sub_components'; interface SourcererComponentProps { scope: sourcererModel.SourcererScopeName; @@ -91,11 +86,12 @@ export const Sourcerer = React.memo(({ scope: scopeId } kibanaDataViews, missingPatterns, scopeId, + selectedDataViewId, selectedPatterns, signalIndexName, }); - const onCheckboxChanged = useCallback( + const onCheckboxChanged: ChangeEventHandler = useCallback( (e) => { setIsOnlyDetectionAlertsChecked(e.target.checked); setDataViewId(defaultDataView.id); @@ -251,49 +247,35 @@ export const Sourcerer = React.memo(({ scope: scopeId } <>{i18n.SELECT_DATA_VIEW} - {isOnlyDetectionAlerts && ( - - )} + - {isModified === 'deprecated' || isModified === 'missingPatterns' ? ( - <> - - setIsShowingUpdateModal(false)} - onContinue={onContinueUpdateDeprecated} - onUpdate={onUpdateDataView} - /> - + {(dataViewId === null && isModified === 'deprecated') || + isModified === 'missingPatterns' ? ( + setIsShowingUpdateModal(false)} + onReset={resetDataSources} + onUpdateStepOne={isModified === 'deprecated' ? onUpdateDeprecated : onUpdateDataView} + onUpdateStepTwo={onUpdateDataView} + selectedPatterns={selectedPatterns} + /> ) : ( <> - {isTimelineSourcerer && ( - - - - )} + {dataViewId && ( (({ scope: scopeId } /> - {!isDetectionsSourcerer && ( - - - - - {i18n.INDEX_PATTERNS_RESET} - - - - - {i18n.SAVE_INDEX_PATTERNS} - - - - - )} + diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/sub_components.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/sub_components.tsx new file mode 100644 index 0000000000000..4d10a880648f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/sub_components.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, { ChangeEventHandler } from 'react'; +import { EuiButton, EuiCallOut, EuiCheckbox, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ResetButton, StyledFormRow } from './helpers'; +import * as i18n from './translations'; + +interface SourcererCalloutProps { + isOnlyDetectionAlerts: boolean; + title: string; +} + +export const SourcererCallout = React.memo( + ({ isOnlyDetectionAlerts, title }) => + isOnlyDetectionAlerts ? ( + + ) : null +); + +SourcererCallout.displayName = 'SourcererCallout'; + +interface AlertsCheckboxProps { + checked: boolean; + isShow: boolean; + onChange: ChangeEventHandler; +} + +export const AlertsCheckbox = React.memo(({ onChange, checked, isShow }) => + isShow ? ( + + + + ) : null +); + +AlertsCheckbox.displayName = 'AlertsCheckbox'; + +interface SaveButtonsProps { + disableSave: boolean; + isShow: boolean; + onReset: () => void; + onSave: () => void; +} + +export const SaveButtons = React.memo( + ({ disableSave, isShow, onReset, onSave }) => + isShow ? ( + + + + + {i18n.INDEX_PATTERNS_RESET} + + + + + {i18n.SAVE_INDEX_PATTERNS} + + + + + ) : null +); + +SaveButtons.displayName = 'SaveButtons'; diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx index 36fae76c7739b..ec55b654b9fcc 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx @@ -21,14 +21,15 @@ import { import React, { useMemo } from 'react'; import * as i18n from './translations'; import { Blockquote, ResetButton } from './helpers'; +import { UpdateDefaultDataViewModal } from './update_default_data_view_modal'; interface Props { activePatterns?: string[]; indicesExist: boolean; isModified: 'deprecated' | 'missingPatterns'; missingPatterns: string[]; - onClick: () => void; - onClose: () => void; + onDismiss: () => void; + onReset: () => void; onUpdate: () => void; selectedPatterns: string[]; } @@ -44,13 +45,13 @@ const translations = { }, }; -export const TemporarySourcerer = React.memo( +export const TemporarySourcererComp = React.memo( ({ activePatterns, indicesExist, isModified, - onClose, - onClick, + onDismiss, + onReset, onUpdate, selectedPatterns, missingPatterns, @@ -141,7 +142,7 @@ export const TemporarySourcerer = React.memo( id="xpack.securitySolution.indexPatterns.toggleToNewSourcerer" defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view {link}." values={{ - link: {i18n.TOGGLE_TO_NEW_SOURCERER}, + link: {i18n.TOGGLE_TO_NEW_SOURCERER}, }} /> )} @@ -158,7 +159,7 @@ export const TemporarySourcerer = React.memo( id="xpack.securitySolution.indexPatterns.missingPatterns.description" defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view {link}." values={{ - link: {i18n.TOGGLE_TO_NEW_SOURCERER}, + link: {i18n.TOGGLE_TO_NEW_SOURCERER}, }} /> @@ -172,7 +173,7 @@ export const TemporarySourcerer = React.memo( aria-label={i18n.INDEX_PATTERNS_CLOSE} data-test-subj="sourcerer-deprecated-close" flush="left" - onClick={onClose} + onClick={onDismiss} title={i18n.INDEX_PATTERNS_CLOSE} > {i18n.INDEX_PATTERNS_CLOSE} @@ -185,4 +186,58 @@ export const TemporarySourcerer = React.memo( } ); +TemporarySourcererComp.displayName = 'TemporarySourcererComp'; + +interface TemporarySourcererProps { + activePatterns?: string[]; + indicesExist: boolean; + isModified: 'deprecated' | 'missingPatterns'; + isShowingUpdateModal: boolean; + missingPatterns: string[]; + onContinueWithoutUpdate: () => void; + onDismiss: () => void; + onDismissModal: () => void; + onReset: () => void; + onUpdateStepOne: () => void; + onUpdateStepTwo: () => void; + selectedPatterns: string[]; +} + +export const TemporarySourcerer = React.memo( + ({ + activePatterns, + indicesExist, + isModified, + missingPatterns, + onContinueWithoutUpdate, + onDismiss, + onReset, + onUpdateStepOne, + onUpdateStepTwo, + selectedPatterns, + isShowingUpdateModal, + onDismissModal, + }) => ( + <> + + + + ) +); + TemporarySourcerer.displayName = 'TemporarySourcerer'; diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/update_default_data_view_modal.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/update_default_data_view_modal.tsx index 78fc6f82fa748..29cace7f075de 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/update_default_data_view_modal.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/update_default_data_view_modal.tsx @@ -25,7 +25,7 @@ import { Blockquote, ResetButton } from './helpers'; interface Props { isShowing: boolean; missingPatterns: string[]; - onClose: () => void; + onDismissModal: () => void; onContinue: () => void; onUpdate: () => void; } @@ -41,9 +41,9 @@ const MyEuiModal = styled(EuiModal)` `; export const UpdateDefaultDataViewModal = React.memo( - ({ isShowing, onClose, onContinue, onUpdate, missingPatterns }) => + ({ isShowing, onDismissModal, onContinue, onUpdate, missingPatterns }) => isShowing ? ( - +

{i18n.UPDATE_SECURITY_DATA_VIEW}

diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx index d7b094ab27b14..ae9990247f920 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx @@ -19,6 +19,7 @@ interface UsePickIndexPatternsProps { kibanaDataViews: sourcererModel.SourcererModel['kibanaDataViews']; missingPatterns: string[]; scopeId: sourcererModel.SourcererScopeName; + selectedDataViewId: string | null; selectedPatterns: string[]; signalIndexName: string | null; } @@ -49,6 +50,7 @@ export const usePickIndexPatterns = ({ kibanaDataViews, missingPatterns, scopeId, + selectedDataViewId, selectedPatterns, signalIndexName, }: UsePickIndexPatternsProps): UsePickIndexPatterns => { @@ -155,11 +157,11 @@ export const usePickIndexPatterns = ({ // when scope updates, check modified to set/remove alerts label useEffect(() => { onSetIsModified( - selectedOptions.map(({ label }) => label), - dataViewId + selectedPatterns.map((pattern) => pattern), + selectedDataViewId ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataViewId, missingPatterns, scopeId, selectedOptions]); + }, [selectedDataViewId, missingPatterns, scopeId, selectedPatterns]); const onChangeCombo = useCallback((newSelectedOptions) => { setSelectedOptions(newSelectedOptions); diff --git a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts index 41fcd29191da2..debdacb570ad0 100644 --- a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts @@ -13,10 +13,10 @@ import { useAppToasts } from '../../hooks/use_app_toasts'; import { useKibana } from '../../lib/kibana'; import { inputsActions } from '../../store/actions'; import { isIndexNotFoundError } from '../../utils/exceptions'; -import { HostsRiskScore } from '../../../../common/search_strategy'; +import { getHostRiskIndex, HostsRiskScore } from '../../../../common/search_strategy'; + import { useHostsRiskScoreComplete } from './use_hosts_risk_score_complete'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; -import { getHostRiskIndex } from '../../../helpers'; export const QUERY_ID = 'host_risk_score'; const noop = () => {}; @@ -104,7 +104,7 @@ export const useHostsRiskScore = ({ timerange: timerange ? { to: timerange.to, from: timerange.from, interval: '' } : undefined, - hostName, + hostNames: hostName ? [hostName] : undefined, defaultIndex: [getHostRiskIndex(space.id)], }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts index 934cb88ee0d86..6faaa3c8f08db 100644 --- a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts @@ -28,7 +28,7 @@ export const getHostsRiskScore = ({ data, defaultIndex, timerange, - hostName, + hostNames, signal, }: GetHostsRiskScoreProps): Observable => data.search.search( @@ -36,7 +36,7 @@ export const getHostsRiskScore = ({ defaultIndex, factoryQueryType: HostsQueries.hostsRiskScore, timerange, - hostName, + hostNames, }, { strategy: 'securitySolutionSearchStrategy', diff --git a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx index a0178d45a9e07..696422dfc89db 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx @@ -39,8 +39,8 @@ const getEsFields = memoizeOne( export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) => void } => { const { data } = useKibana().services; - const abortCtrl = useRef(new AbortController()); - const searchSubscription$ = useRef(new Subscription()); + const abortCtrl = useRef>({}); + const searchSubscription$ = useRef>({}); const dispatch = useDispatch(); const { addError, addWarning } = useAppToasts(); @@ -54,16 +54,19 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) const indexFieldsSearch = useCallback( (selectedDataViewId: string) => { const asyncSearch = async () => { - abortCtrl.current = new AbortController(); + abortCtrl.current = { + ...abortCtrl.current, + [selectedDataViewId]: new AbortController(), + }; setLoading({ id: selectedDataViewId, loading: true }); - searchSubscription$.current = data.search + const subscription = data.search .search, IndexFieldsStrategyResponse>( { dataViewId: selectedDataViewId, onlyCheckIfIndicesExist: false, }, { - abortSignal: abortCtrl.current.signal, + abortSignal: abortCtrl.current[selectedDataViewId].signal, strategy: 'indexFields', } ) @@ -82,11 +85,15 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) runtimeMappings: response.runtimeMappings, }) ); - searchSubscription$.current.unsubscribe(); + if (searchSubscription$.current[selectedDataViewId]) { + searchSubscription$.current[selectedDataViewId].unsubscribe(); + } } else if (isErrorResponse(response)) { setLoading({ id: selectedDataViewId, loading: false }); addWarning(i18n.ERROR_BEAT_FIELDS); - searchSubscription$.current.unsubscribe(); + if (searchSubscription$.current[selectedDataViewId]) { + searchSubscription$.current[selectedDataViewId].unsubscribe(); + } } }, error: (msg) => { @@ -98,12 +105,23 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) addError(msg, { title: i18n.FAIL_BEAT_FIELDS, }); - searchSubscription$.current.unsubscribe(); + if (searchSubscription$.current[selectedDataViewId]) { + searchSubscription$.current[selectedDataViewId].unsubscribe(); + } }, }); + searchSubscription$.current = { + ...searchSubscription$.current, + [selectedDataViewId]: subscription, + }; }; - searchSubscription$.current.unsubscribe(); - abortCtrl.current.abort(); + if (searchSubscription$.current[selectedDataViewId] != null) { + searchSubscription$.current[selectedDataViewId].unsubscribe(); + } + + if (abortCtrl.current[selectedDataViewId] != null) { + abortCtrl.current[selectedDataViewId].abort(); + } asyncSearch(); }, [addError, addWarning, data.search, dispatch, setLoading] @@ -111,8 +129,10 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) useEffect(() => { return () => { - searchSubscription$.current.unsubscribe(); - abortCtrl.current.abort(); + Object.values(searchSubscription$.current).forEach((subscription) => + subscription.unsubscribe() + ); + Object.values(abortCtrl.current).forEach((signal) => signal.abort()); }; }, []); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx index 2e060973c431f..5a00fb8d986d5 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx @@ -52,13 +52,15 @@ jest.mock('../../utils/route/use_route_spy', () => ({ useRouteSpy: () => [mockRouteSpy], })); +const mockSearch = jest.fn(); + jest.mock('../../lib/kibana', () => ({ - useToasts: jest.fn().mockReturnValue({ + useToasts: () => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), }), - useKibana: jest.fn().mockReturnValue({ + useKibana: () => ({ services: { application: { capabilities: { @@ -72,7 +74,7 @@ jest.mock('../../lib/kibana', () => ({ getTitles: jest.fn().mockImplementation(() => Promise.resolve(mockPatterns)), }, search: { - search: jest.fn().mockImplementation(() => ({ + search: mockSearch.mockImplementation(() => ({ subscribe: jest.fn().mockImplementation(() => ({ error: jest.fn(), next: jest.fn(), @@ -188,6 +190,8 @@ describe('Sourcerer Hooks', () => { type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING', payload: { loading: false }, }); + expect(mockDispatch).toHaveBeenCalledTimes(7); + expect(mockSearch).toHaveBeenCalledTimes(2); }); }); }); @@ -216,6 +220,48 @@ describe('Sourcerer Hooks', () => { }); }); }); + it('index field search is not repeated when default and timeline have same dataViewId', async () => { + await act(async () => { + const { rerender, waitForNextUpdate } = renderHook(() => useInitSourcerer(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); + rerender(); + await waitFor(() => { + expect(mockSearch).toHaveBeenCalledTimes(1); + }); + }); + }); + it('index field search called twice when default and timeline have different dataViewId', async () => { + store = createStore( + { + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + selectedDataViewId: 'different-id', + }, + }, + }, + }, + SUB_PLUGINS_REDUCER, + kibanaObservable, + storage + ); + await act(async () => { + const { rerender, waitForNextUpdate } = renderHook(() => useInitSourcerer(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); + rerender(); + await waitFor(() => { + expect(mockSearch).toHaveBeenCalledTimes(2); + }); + }); + }); describe('useSourcererDataView', () => { it('Should exclude elastic cloud alias when selected patterns include "logs-*" as an alias', async () => { diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index c493cb528d09a..f0872d5cebf4c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -84,8 +84,15 @@ export const useInitSourcerer = ( ); const { indexFieldsSearch } = useDataView(); + const searchedIds = useRef([]); useEffect( - () => activeDataViewIds.forEach((id) => id != null && id.length > 0 && indexFieldsSearch(id)), + () => + activeDataViewIds.forEach((id) => { + if (id != null && id.length > 0 && !searchedIds.current.includes(id)) { + searchedIds.current = [...searchedIds.current, id]; + indexFieldsSearch(id); + } + }), [activeDataViewIds, indexFieldsSearch] ); @@ -180,28 +187,33 @@ export const useInitSourcerer = ( }, [defaultDataView.title, dispatch, indexFieldsSearch, addError] ); - useEffect(() => { + + const onSignalIndexUpdated = useCallback(() => { if ( !loadingSignalIndex && signalIndexName != null && signalIndexNameSourcerer == null && defaultDataView.id.length > 0 ) { - // update signal name also updates sourcerer - // we hit this the first time signal index is created updateSourcererDataView(signalIndexName); dispatch(sourcererActions.setSignalIndexName({ signalIndexName })); } }, [ - defaultDataView.id, + defaultDataView.id.length, dispatch, - indexFieldsSearch, - isSignalIndexExists, loadingSignalIndex, signalIndexName, signalIndexNameSourcerer, updateSourcererDataView, ]); + + useEffect(() => { + onSignalIndexUpdated(); + // because we only want onSignalIndexUpdated to run when signalIndexName updates, + // but we want to know about the updates from the dependencies of onSignalIndexUpdated + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signalIndexName]); + // Related to the detection page useEffect(() => { if ( diff --git a/x-pack/plugins/security_solution/public/common/lib/helpers/scheduler.ts b/x-pack/plugins/security_solution/public/common/lib/helpers/scheduler.ts index 248f0a2a0f65b..89c51757e420f 100644 --- a/x-pack/plugins/security_solution/public/common/lib/helpers/scheduler.ts +++ b/x-pack/plugins/security_solution/public/common/lib/helpers/scheduler.ts @@ -7,35 +7,11 @@ import { scaleLog } from 'd3-scale'; -// Types are from: https://github.com/Microsoft/TypeScript/issues/21309 -// TODO: Once this is no longer an experimental web API, remove these below -// as they should be Typed by TypeScript -type RequestIdleCallbackHandle = number; -interface RequestIdleCallbackOptions { - timeout: number; -} -interface RequestIdleCallbackDeadline { - readonly didTimeout: boolean; - timeRemaining: () => number; -} - -declare global { - interface Window { - requestIdleCallback: ( - callback: (deadline: RequestIdleCallbackDeadline) => void, - opts?: RequestIdleCallbackOptions - ) => RequestIdleCallbackHandle; - cancelIdleCallback: (handle: RequestIdleCallbackHandle) => void; - } -} - /** * Polyfill is from: https://developers.google.com/web/updates/2015/08/using-requestidlecallback * This is for Safari 12.1.2 and IE-11 */ -export const polyFillRequestIdleCallback = ( - callback: (deadline: RequestIdleCallbackDeadline) => void -) => { +export const polyFillRequestIdleCallback = (callback: IdleRequestCallback) => { const start = Date.now(); return setTimeout(() => { callback({ @@ -59,8 +35,8 @@ export const polyFillRequestIdleCallback = ( * this and all usages. Otherwise, just remove this note */ export const requestIdleCallbackViaScheduler = ( - callback: (deadline: RequestIdleCallbackDeadline) => void, - opts?: RequestIdleCallbackOptions + callback: IdleRequestCallback, + opts?: IdleRequestOptions ) => { if ('requestIdleCallback' in window) { window.requestIdleCallback(callback, opts); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx index 13e93604863b4..aab6cabdb3a93 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx @@ -7,7 +7,7 @@ import { ExistsFilter, Filter } from '@kbn/es-query'; import { - buildAlertsRuleIdFilter, + buildAlertsFilter, buildAlertStatusesFilter, buildAlertStatusFilter, buildThreatMatchFilter, @@ -18,21 +18,21 @@ jest.mock('./actions'); describe('alerts default_config', () => { describe('buildAlertsRuleIdFilter', () => { test('given a rule id this will return an array with a single filter', () => { - const filters: Filter[] = buildAlertsRuleIdFilter('rule-id-1'); + const filters: Filter[] = buildAlertsFilter('rule-id-1'); const expectedFilter: Filter = { meta: { alias: null, negate: false, disabled: false, type: 'phrase', - key: 'kibana.alert.rule.uuid', + key: 'kibana.alert.rule.rule_id', params: { query: 'rule-id-1', }, }, query: { match_phrase: { - 'kibana.alert.rule.uuid': 'rule-id-1', + 'kibana.alert.rule.rule_id': 'rule-id-1', }, }, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index a5947e45ed0f0..663d133f04b1c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -6,21 +6,13 @@ */ import { - ALERT_DURATION, - ALERT_RULE_PRODUCER, - ALERT_START, + ALERT_BUILDING_BLOCK_TYPE, ALERT_WORKFLOW_STATUS, - ALERT_UUID, - ALERT_RULE_UUID, - ALERT_RULE_NAME, - ALERT_RULE_CATEGORY, - ALERT_RULE_SEVERITY, - ALERT_RULE_RISK_SCORE, + ALERT_RULE_RULE_ID, } from '@kbn/rule-data-utils/technical_field_names'; import type { Filter } from '@kbn/es-query'; -import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { ColumnHeaderOptions, RowRendererId } from '../../../../common/types/timeline'; +import { RowRendererId } from '../../../../common/types/timeline'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; @@ -34,12 +26,12 @@ export const buildAlertStatusFilter = (status: Status): Filter[] => { should: [ { term: { - 'kibana.alert.workflow_status': status, + [ALERT_WORKFLOW_STATUS]: status, }, }, { term: { - 'kibana.alert.workflow_status': 'in-progress', + [ALERT_WORKFLOW_STATUS]: 'in-progress', }, }, ], @@ -47,7 +39,7 @@ export const buildAlertStatusFilter = (status: Status): Filter[] => { } : { term: { - 'kibana.alert.workflow_status': status, + [ALERT_WORKFLOW_STATUS]: status, }, }; @@ -58,7 +50,7 @@ export const buildAlertStatusFilter = (status: Status): Filter[] => { negate: false, disabled: false, type: 'phrase', - key: 'kibana.alert.workflow_status', + key: ALERT_WORKFLOW_STATUS, params: { query: status, }, @@ -76,7 +68,7 @@ export const buildAlertStatusesFilter = (statuses: Status[]): Filter[] => { bool: { should: statuses.map((status) => ({ term: { - 'kibana.alert.workflow_status': status, + [ALERT_WORKFLOW_STATUS]: status, }, })), }, @@ -94,8 +86,15 @@ export const buildAlertStatusesFilter = (statuses: Status[]): Filter[] => { ]; }; -export const buildAlertsRuleIdFilter = (ruleId: string | null): Filter[] => - ruleId +/** + * Builds Kuery filter for fetching alerts for a specific rule. Takes the rule's + * static id, i.e. `rule.ruleId` (not rule.id), so that alerts for _all + * historical instances_ of the rule are returned. + * + * @param ruleStaticId Rule's static id: `rule.ruleId` + */ +export const buildAlertsFilter = (ruleStaticId: string | null): Filter[] => + ruleStaticId ? [ { meta: { @@ -103,14 +102,14 @@ export const buildAlertsRuleIdFilter = (ruleId: string | null): Filter[] => negate: false, disabled: false, type: 'phrase', - key: 'kibana.alert.rule.uuid', + key: ALERT_RULE_RULE_ID, params: { - query: ruleId, + query: ruleStaticId, }, }, query: { match_phrase: { - 'kibana.alert.rule.uuid': ruleId, + [ALERT_RULE_RULE_ID]: ruleStaticId, }, }, }, @@ -127,10 +126,10 @@ export const buildShowBuildingBlockFilter = (showBuildingBlockAlerts: boolean): negate: true, disabled: false, type: 'exists', - key: 'kibana.alert.building_block_type', + key: ALERT_BUILDING_BLOCK_TYPE, value: 'exists', }, - query: { exists: { field: 'kibana.alert.building_block_type' } }, + query: { exists: { field: ALERT_BUILDING_BLOCK_TYPE } }, }, ]; @@ -183,121 +182,3 @@ export const requiredFieldsForActions = [ 'host.os.family', 'event.code', ]; - -// TODO: Once we are past experimental phase this code should be removed -export const buildAlertStatusFilterRuleRegistry = (status: Status): Filter[] => { - const combinedQuery = - status === 'acknowledged' - ? { - bool: { - should: [ - { - term: { - [ALERT_WORKFLOW_STATUS]: status, - }, - }, - { - term: { - [ALERT_WORKFLOW_STATUS]: 'in-progress', - }, - }, - ], - }, - } - : { - term: { - [ALERT_WORKFLOW_STATUS]: status, - }, - }; - - return [ - { - meta: { - alias: null, - negate: false, - disabled: false, - type: 'phrase', - key: ALERT_WORKFLOW_STATUS, - params: { - query: status, - }, - }, - query: combinedQuery, - }, - ]; -}; - -// TODO: Once we are past experimental phase this code should be removed -export const buildAlertStatusesFilterRuleRegistry = (statuses: Status[]): Filter[] => { - const combinedQuery = { - bool: { - should: statuses.map((status) => ({ - term: { - [ALERT_WORKFLOW_STATUS]: status, - }, - })), - }, - }; - - return [ - { - meta: { - alias: null, - negate: false, - disabled: false, - }, - query: combinedQuery, - }, - ]; -}; - -export const buildShowBuildingBlockFilterRuleRegistry = ( - showBuildingBlockAlerts: boolean -): Filter[] => - showBuildingBlockAlerts - ? [] - : [ - { - meta: { - alias: null, - negate: true, - disabled: false, - type: 'exists', - key: 'kibana.alert.building_block_type', - value: 'exists', - }, - query: { exists: { field: 'kibana.alert.building_block_type' } }, - }, - ]; - -export const requiredFieldMappingsForActionsRuleRegistry = { - '@timestamp': '@timestamp', - 'event.kind': 'event.kind', - 'rule.severity': ALERT_RULE_SEVERITY, - 'rule.risk_score': ALERT_RULE_RISK_SCORE, - 'alert.uuid': ALERT_UUID, - 'alert.start': ALERT_START, - 'event.action': 'event.action', - 'alert.workflow_status': ALERT_WORKFLOW_STATUS, - 'alert.duration.us': ALERT_DURATION, - 'rule.uuid': ALERT_RULE_UUID, - 'rule.name': ALERT_RULE_NAME, - 'rule.category': ALERT_RULE_CATEGORY, - producer: ALERT_RULE_PRODUCER, - tags: 'tags', -}; - -export const alertsHeadersRuleRegistry: ColumnHeaderOptions[] = Object.entries( - requiredFieldMappingsForActionsRuleRegistry -).map(([alias, field]) => ({ - columnHeaderType: defaultColumnHeaderType, - displayAsText: alias, - id: field, -})); - -export const alertsDefaultModelRuleRegistry: SubsetTimelineModel = { - ...timelineDefaults, - columns: alertsHeadersRuleRegistry, - showCheckboxes: true, - excludedRowRendererIds: Object.values(RowRendererId), -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index bbab423738ca0..256a063c44158 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -40,9 +40,7 @@ import { updateAlertStatusAction } from './actions'; import { AditionalFiltersAction, AlertsUtilityBar } from './alerts_utility_bar'; import { alertsDefaultModel, - alertsDefaultModelRuleRegistry, buildAlertStatusFilter, - buildAlertStatusFilterRuleRegistry, requiredFieldsForActions, } from './default_config'; import { buildTimeRangeFilter } from './helpers'; @@ -106,8 +104,6 @@ export const AlertsTableComponent: React.FC = ({ const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); const { addWarning } = useAppToasts(); - // TODO: Once we are past experimental phase this code should be removed - const ruleRegistryEnabled = useIsExperimentalFeatureEnabled('ruleRegistryEnabled'); const ACTION_BUTTON_COUNT = 4; const getGlobalQuery = useCallback( @@ -247,14 +243,9 @@ export const AlertsTableComponent: React.FC = ({ refetchQuery: inputsModel.Refetch, { status, selectedStatus }: UpdateAlertsStatusProps ) => { - // TODO: Once we are past experimental phase this code should be removed - const currentStatusFilter = ruleRegistryEnabled - ? buildAlertStatusFilterRuleRegistry(status) - : buildAlertStatusFilter(status); - await updateAlertStatusAction({ query: showClearSelectionAction - ? getGlobalQuery(currentStatusFilter)?.filterQuery + ? getGlobalQuery(buildAlertStatusFilter(status))?.filterQuery : undefined, alertIds: Object.keys(selectedEventIds), selectedStatus, @@ -273,7 +264,6 @@ export const AlertsTableComponent: React.FC = ({ showClearSelectionAction, onAlertStatusUpdateSuccess, onAlertStatusUpdateFailure, - ruleRegistryEnabled, ] ); @@ -327,24 +317,16 @@ export const AlertsTableComponent: React.FC = ({ ); const defaultFiltersMemo = useMemo(() => { - // TODO: Once we are past experimental phase this code should be removed - const alertStatusFilter = ruleRegistryEnabled - ? buildAlertStatusFilterRuleRegistry(filterGroup) - : buildAlertStatusFilter(filterGroup); + const alertStatusFilter = buildAlertStatusFilter(filterGroup); if (isEmpty(defaultFilters)) { return alertStatusFilter; } else if (defaultFilters != null && !isEmpty(defaultFilters)) { return [...defaultFilters, ...alertStatusFilter]; } - }, [defaultFilters, filterGroup, ruleRegistryEnabled]); + }, [defaultFilters, filterGroup]); const { filterManager } = useKibana().services.data.query; - // TODO: Once we are past experimental phase this code should be removed - const defaultTimelineModel = ruleRegistryEnabled - ? alertsDefaultModelRuleRegistry - : alertsDefaultModel; - const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); useEffect(() => { @@ -359,7 +341,7 @@ export const AlertsTableComponent: React.FC = ({ : c ), documentType: i18n.ALERTS_DOCUMENT_TYPE, - excludedRowRendererIds: defaultTimelineModel.excludedRowRendererIds as RowRendererId[], + excludedRowRendererIds: alertsDefaultModel.excludedRowRendererIds as RowRendererId[], filterManager, footerText: i18n.TOTAL_COUNT_OF_ALERTS, id: timelineId, @@ -370,7 +352,7 @@ export const AlertsTableComponent: React.FC = ({ showCheckboxes: true, }) ); - }, [dispatch, defaultTimelineModel, filterManager, tGridEnabled, timelineId]); + }, [dispatch, filterManager, tGridEnabled, timelineId]); const leadingControlColumns = useMemo(() => getDefaultControlColumn(ACTION_BUTTON_COUNT), []); @@ -383,7 +365,7 @@ export const AlertsTableComponent: React.FC = ({ additionalFilters={additionalFiltersComponent} currentFilter={filterGroup} defaultCellActions={defaultCellActions} - defaultModel={defaultTimelineModel} + defaultModel={alertsDefaultModel} end={to} entityType="events" hasAlertsCrud={hasIndexWrite && hasIndexMaintenance} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index 309c6c7f9761c..1897ad45fe7ff 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -265,6 +265,20 @@ export const STATUS = i18n.translate( } ); +export const CHANGE_ALERT_STATUS = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.overview.changeAlertStatus', + { + defaultMessage: 'Change alert status', + } +); + +export const CLICK_TO_CHANGE_ALERT_STATUS = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.overview.clickToChangeAlertStatus', + { + defaultMessage: 'Click to change alert status', + } +); + export const SIGNAL_STATUS = i18n.translate( 'xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle', { @@ -278,10 +292,3 @@ export const TRIGGERED = i18n.translate( defaultMessage: 'Triggered', } ); - -export const TIMESTAMP = i18n.translate( - 'xpack.securitySolution.eventsViewer.alerts.overviewTable.timestampTitle', - { - defaultMessage: 'Timestamp', - } -); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx deleted file mode 100644 index 2e6991f87ec5a..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx +++ /dev/null @@ -1,128 +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 from 'react'; -import { mount } from 'enzyme'; - -import * as i18n from '../rule_preview/translations'; -import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { TestProviders } from '../../../../common/mock'; -import { PreviewCustomQueryHistogram } from './custom_histogram'; - -jest.mock('../../../../common/containers/use_global_time'); - -describe('PreviewCustomQueryHistogram', () => { - const mockSetQuery = jest.fn(); - - beforeEach(() => { - (useGlobalTime as jest.Mock).mockReturnValue({ - from: '2020-07-07T08:20:18.966Z', - isInitializing: false, - to: '2020-07-08T08:20:18.966Z', - setQuery: mockSetQuery, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('it renders loader when isLoading is true', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); - expect( - wrapper.find('[dataTestSubj="queryPreviewCustomHistogram"]').at(0).prop('subtitle') - ).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING); - }); - - test('it configures data and subtitle', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); - expect( - wrapper.find('[dataTestSubj="queryPreviewCustomHistogram"]').at(0).prop('subtitle') - ).toEqual(i18n.QUERY_PREVIEW_TITLE(9154)); - expect(wrapper.find('[dataTestSubj="queryPreviewCustomHistogram"]').at(0).props().data).toEqual( - [ - { - key: 'hits', - value: [ - { - g: 'All others', - x: 1602247050000, - y: 2314, - }, - { - g: 'All others', - x: 1602247162500, - y: 3471, - }, - { - g: 'All others', - x: 1602247275000, - y: 3369, - }, - ], - }, - ] - ); - }); - - test('it invokes setQuery with id, inspect, isLoading and refetch', async () => { - const mockRefetch = jest.fn(); - - mount( - - - - ); - - expect(mockSetQuery).toHaveBeenCalledWith({ - id: 'queryPreviewCustomHistogramQuery', - inspect: { dsl: ['some dsl'], response: ['query response'] }, - loading: false, - refetch: mockRefetch, - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx deleted file mode 100644 index 5392b08889128..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx +++ /dev/null @@ -1,76 +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, { useEffect, useMemo } from 'react'; - -import * as i18n from '../rule_preview/translations'; -import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { getHistogramConfig } from '../rule_preview/helpers'; -import { - ChartSeriesConfigs, - ChartSeriesData, - ChartData, -} from '../../../../common/components/charts/common'; -import { InspectResponse } from '../../../../../public/types'; -import { inputsModel } from '../../../../common/store'; -import { PreviewHistogram } from './histogram'; - -export const ID = 'queryPreviewCustomHistogramQuery'; - -interface PreviewCustomQueryHistogramProps { - to: string; - from: string; - isLoading: boolean; - data: ChartData[]; - totalCount: number; - inspect: InspectResponse; - refetch: inputsModel.Refetch; -} - -export const PreviewCustomQueryHistogram = ({ - to, - from, - data, - totalCount, - inspect, - refetch, - isLoading, -}: PreviewCustomQueryHistogramProps) => { - const { setQuery, isInitializing } = useGlobalTime(); - - useEffect((): void => { - if (!isLoading && !isInitializing) { - setQuery({ id: ID, inspect, loading: isLoading, refetch }); - } - }, [setQuery, inspect, isLoading, isInitializing, refetch]); - - const barConfig = useMemo( - (): ChartSeriesConfigs => getHistogramConfig(to, from, true), - [from, to] - ); - - const subtitle = useMemo( - (): string => - isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), - [isLoading, totalCount] - ); - - const chartData = useMemo((): ChartSeriesData[] => [{ key: 'hits', value: data }], [data]); - - return ( - - ); -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx deleted file mode 100644 index df32223fc7ec3..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx +++ /dev/null @@ -1,152 +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 from 'react'; -import { mount } from 'enzyme'; - -import * as i18n from '../rule_preview/translations'; -import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { TestProviders } from '../../../../common/mock'; -import { PreviewEqlQueryHistogram } from './eql_histogram'; - -jest.mock('../../../../common/containers/use_global_time'); - -describe('PreviewEqlQueryHistogram', () => { - const mockSetQuery = jest.fn(); - - beforeEach(() => { - (useGlobalTime as jest.Mock).mockReturnValue({ - from: '2020-07-07T08:20:18.966Z', - isInitializing: false, - to: '2020-07-08T08:20:18.966Z', - setQuery: mockSetQuery, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('it renders loader when isLoading is true', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); - expect( - wrapper.find('[dataTestSubj="queryPreviewEqlHistogram"]').at(0).prop('subtitle') - ).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING); - }); - - test('it configures data and subtitle', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); - expect( - wrapper.find('[dataTestSubj="queryPreviewEqlHistogram"]').at(0).prop('subtitle') - ).toEqual(i18n.QUERY_PREVIEW_TITLE(9154)); - expect(wrapper.find('[dataTestSubj="queryPreviewEqlHistogram"]').at(0).props().data).toEqual([ - { - key: 'hits', - value: [ - { - g: 'All others', - x: 1602247050000, - y: 2314, - }, - { - g: 'All others', - x: 1602247162500, - y: 3471, - }, - { - g: 'All others', - x: 1602247275000, - y: 3369, - }, - ], - }, - ]); - }); - - test('it invokes setQuery with id, inspect, isLoading and refetch', async () => { - const mockRefetch = jest.fn(); - - mount( - - - - ); - - expect(mockSetQuery).toHaveBeenCalledWith({ - id: 'queryEqlPreviewHistogramQuery', - inspect: { dsl: ['some dsl'], response: ['query response'] }, - loading: false, - refetch: mockRefetch, - }); - }); - - test('it displays histogram', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); - expect( - wrapper.find('[data-test-subj="sharedPreviewQueryNoHistogramAvailable"]').exists() - ).toBeFalsy(); - expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx deleted file mode 100644 index eae2a593d5f25..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx +++ /dev/null @@ -1,73 +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, { useEffect, useMemo } from 'react'; - -import * as i18n from '../rule_preview/translations'; -import { getHistogramConfig } from '../rule_preview/helpers'; -import { - ChartSeriesData, - ChartSeriesConfigs, - ChartData, -} from '../../../../common/components/charts/common'; -import { InspectQuery } from '../../../../common/store/inputs/model'; -import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { inputsModel } from '../../../../common/store'; -import { PreviewHistogram } from './histogram'; - -export const ID = 'queryEqlPreviewHistogramQuery'; - -interface PreviewEqlQueryHistogramProps { - to: string; - from: string; - totalCount: number; - isLoading: boolean; - data: ChartData[]; - inspect: InspectQuery; - refetch: inputsModel.Refetch; -} - -export const PreviewEqlQueryHistogram = ({ - from, - to, - totalCount, - data, - inspect, - refetch, - isLoading, -}: PreviewEqlQueryHistogramProps) => { - const { setQuery, isInitializing } = useGlobalTime(); - - useEffect((): void => { - if (!isInitializing) { - setQuery({ id: ID, inspect, loading: false, refetch }); - } - }, [setQuery, inspect, isInitializing, refetch]); - - const barConfig = useMemo((): ChartSeriesConfigs => getHistogramConfig(to, from), [from, to]); - - const subtitle = useMemo( - (): string => - isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), - [isLoading, totalCount] - ); - - const chartData = useMemo((): ChartSeriesData[] => [{ key: 'hits', value: data }], [data]); - - return ( - - ); -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx deleted file mode 100644 index 500a7f3d0e3db..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx +++ /dev/null @@ -1,74 +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 from 'react'; -import { mount } from 'enzyme'; - -import { TestProviders } from '../../../../common/mock'; -import { PreviewHistogram } from './histogram'; -import { getHistogramConfig } from '../rule_preview/helpers'; - -describe('PreviewHistogram', () => { - test('it renders loading icon if "isLoading" is true', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeFalsy(); - }); - - test('it renders chart if "isLoading" is true', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.tsx deleted file mode 100644 index 3391ed1c5560a..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.tsx +++ /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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer, EuiLoadingChart } from '@elastic/eui'; -import styled from 'styled-components'; - -import { BarChart } from '../../../../common/components/charts/barchart'; -import { Panel } from '../../../../common/components/panel'; -import { HeaderSection } from '../../../../common/components/header_section'; -import { ChartSeriesData, ChartSeriesConfigs } from '../../../../common/components/charts/common'; - -const LoadingChart = styled(EuiLoadingChart)` - display: block; - margin: 0 auto; -`; - -interface PreviewHistogramProps { - id: string; - data: ChartSeriesData[]; - dataTestSubj?: string; - barConfig: ChartSeriesConfigs; - title: string; - subtitle: string; - disclaimer: string; - isLoading: boolean; -} - -export const PreviewHistogram = ({ - id, - data, - dataTestSubj, - barConfig, - title, - subtitle, - disclaimer, - isLoading, -}: PreviewHistogramProps) => { - return ( - <> - - - - - - - {isLoading ? ( - - ) : ( - - )} - - - <> - - -

{disclaimer}

-
- -
-
-
- - ); -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx deleted file mode 100644 index f14bd5f7354d9..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx +++ /dev/null @@ -1,502 +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 from 'react'; -import { of } from 'rxjs'; -import { ThemeProvider } from 'styled-components'; -import { mount } from 'enzyme'; - -import { TestProviders } from '../../../../common/mock'; -import { useKibana } from '../../../../common/lib/kibana'; -import { PreviewQuery } from './'; -import { getMockEqlResponse } from '../../../../common/hooks/eql/eql_search_response.mock'; -import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram'; -import { useEqlPreview } from '../../../../common/hooks/eql/'; -import { getMockTheme } from '../../../../common/lib/kibana/kibana_react.mock'; -import type { FilterMeta } from '@kbn/es-query'; - -const mockTheme = getMockTheme({ - eui: { - euiSuperDatePickerWidth: '180px', - }, -}); - -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/containers/matrix_histogram'); -jest.mock('../../../../common/hooks/eql/'); - -describe('PreviewQuery', () => { - beforeEach(() => { - useKibana().services.notifications.toasts.addError = jest.fn(); - - useKibana().services.notifications.toasts.addWarning = jest.fn(); - - (useMatrixHistogram as jest.Mock).mockReturnValue([ - false, - { - inspect: { dsl: [], response: [] }, - totalCount: 1, - refetch: jest.fn(), - data: [], - buckets: [], - }, - (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of(getMockEqlResponse()) - ), - ]); - - (useEqlPreview as jest.Mock).mockReturnValue([ - false, - (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of(getMockEqlResponse()) - ), - { - inspect: { dsl: [], response: [] }, - totalCount: 1, - refetch: jest.fn(), - data: [], - buckets: [], - }, - ]); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('it renders timeframe select and preview button on render', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="queryPreviewSelect"]').exists()).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled - ).toBeFalsy(); - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy(); - }); - - test('it renders preview button disabled if "isDisabled" is true', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled - ).toBeTruthy(); - }); - - test('it renders preview button disabled if "query" is undefined', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled - ).toBeTruthy(); - }); - - test('it renders preview button enabled if query exists', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled - ).toBeFalsy(); - }); - - test('it renders preview button enabled if no query exists but filters do exist', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled - ).toBeFalsy(); - }); - - test('it renders query histogram when rule type is query and preview button clicked', () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; - - expect(mockCalls.length).toEqual(1); - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy(); - }); - - test('it renders noise warning when rule type is query, timeframe is last hour and hit average is greater than 1/hour', async () => { - const wrapper = mount( - - - - ); - - (useMatrixHistogram as jest.Mock).mockReturnValue([ - false, - { - inspect: { dsl: [], response: [] }, - totalCount: 2, - refetch: jest.fn(), - data: [], - buckets: [], - }, - (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of(getMockEqlResponse()) - ), - ]); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); - }); - - test('it renders query histogram when rule type is saved_query and preview button clicked', () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; - - expect(mockCalls.length).toEqual(1); - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy(); - }); - - test('it renders eql histogram when preview button clicked and rule type is eql', () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; - - expect(mockCalls.length).toEqual(1); - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeTruthy(); - }); - - test('it renders noise warning when rule type is eql, timeframe is last hour and hit average is greater than 1/hour', async () => { - const wrapper = mount( - - - - ); - - (useEqlPreview as jest.Mock).mockReturnValue([ - false, - (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of(getMockEqlResponse()) - ), - { - inspect: { dsl: [], response: [] }, - totalCount: 2, - refetch: jest.fn(), - data: [], - buckets: [], - }, - ]); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); - }); - - test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => { - const wrapper = mount( - - - - ); - - (useMatrixHistogram as jest.Mock).mockReturnValue([ - false, - { - inspect: { dsl: [], response: [] }, - totalCount: 500, - refetch: jest.fn(), - data: [], - buckets: [{ key: 'siem-kibana', doc_count: 500 }], - }, - (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of(getMockEqlResponse()) - ), - ]); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; - - expect(mockCalls.length).toEqual(1); - expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy(); - }); - - test('it renders noise warning when rule type is threshold, and threshold field is defined, timeframe is last hour and hit average is greater than 1/hour', async () => { - const wrapper = mount( - - - - ); - - (useMatrixHistogram as jest.Mock).mockReturnValue([ - false, - { - inspect: { dsl: [], response: [] }, - totalCount: 500, - refetch: jest.fn(), - data: [], - buckets: [ - { key: 'siem-kibana', doc_count: 200 }, - { key: 'siem-windows', doc_count: 300 }, - ], - }, - (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of(getMockEqlResponse()) - ), - ]); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); - }); - - test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is empty array', () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; - - expect(mockCalls.length).toEqual(1); - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy(); - }); - - test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is empty string', () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; - - expect(mockCalls.length).toEqual(1); - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy(); - }); - - test('it hides histogram when timeframe changes', () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); - - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy(); - - wrapper - .find('[data-test-subj="queryPreviewTimeframeSelect"] select') - .at(0) - .simulate('change', { target: { value: 'd' } }); - - expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeFalsy(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx deleted file mode 100644 index e7cc34ef49bef..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx +++ /dev/null @@ -1,362 +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, { Fragment, useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; -import { Unit } from '@elastic/datemath'; -import styled from 'styled-components'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSelect, - EuiFormRow, - EuiButton, - EuiCallOut, - EuiText, - EuiSpacer, -} from '@elastic/eui'; -import { debounce } from 'lodash/fp'; - -import { Type } from '@kbn/securitysolution-io-ts-alerting-types'; -import * as i18n from '../rule_preview/translations'; -import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram'; -import { MatrixHistogramType } from '../../../../../common/search_strategy'; -import { FieldValueQueryBar } from '../query_bar'; -import { PreviewEqlQueryHistogram } from './eql_histogram'; -import { useEqlPreview } from '../../../../common/hooks/eql/'; -import { PreviewThresholdQueryHistogram } from './threshold_histogram'; -import { formatDate } from '../../../../common/components/super_date_picker'; -import { State, queryPreviewReducer } from './reducer'; -import { isNoisy } from '../rule_preview/helpers'; -import { PreviewCustomQueryHistogram } from './custom_histogram'; -import { FieldValueThreshold } from '../threshold_input'; - -const Select = styled(EuiSelect)` - width: ${({ theme }) => theme.eui.euiSuperDatePickerWidth}; -`; - -const PreviewButton = styled(EuiButton)` - margin-left: 0; -`; - -export const initialState: State = { - timeframeOptions: [], - showHistogram: false, - timeframe: 'h', - warnings: [], - queryFilter: undefined, - toTime: '', - fromTime: '', - queryString: '', - language: 'kuery', - filters: [], - thresholdFieldExists: false, - showNonEqlHistogram: false, -}; - -export type Threshold = FieldValueThreshold | undefined; - -interface PreviewQueryProps { - dataTestSubj: string; - idAria: string; - query: FieldValueQueryBar | undefined; - index: string[]; - ruleType: Type; - threshold: Threshold; - isDisabled: boolean; -} - -export const PreviewQuery = ({ - ruleType, - dataTestSubj, - idAria, - query, - index, - threshold, - isDisabled, -}: PreviewQueryProps) => { - const [ - eqlQueryLoading, - startEql, - { - totalCount: eqlQueryTotal, - data: eqlQueryData, - refetch: eqlQueryRefetch, - inspect: eqlQueryInspect, - }, - ] = useEqlPreview(); - - const [ - { - thresholdFieldExists, - showNonEqlHistogram, - timeframeOptions, - showHistogram, - timeframe, - warnings, - queryFilter, - toTime, - fromTime, - queryString, - }, - dispatch, - ] = useReducer(queryPreviewReducer(), { - ...initialState, - toTime: formatDate('now-1h'), - fromTime: formatDate('now'), - }); - const [ - isMatrixHistogramLoading, - { inspect, totalCount: matrixHistTotal, refetch, data: matrixHistoData, buckets }, - startNonEql, - ] = useMatrixHistogram({ - errorMessage: i18n.QUERY_PREVIEW_ERROR, - endDate: fromTime, - startDate: toTime, - filterQuery: queryFilter, - indexNames: index, - includeMissingData: false, - histogramType: MatrixHistogramType.events, - stackByField: 'event.category', - threshold: ruleType === 'threshold' ? threshold : undefined, - skip: true, - }); - - const setQueryInfo = useCallback( - (queryBar: FieldValueQueryBar | undefined, indices: string[], type: Type): void => { - dispatch({ - type: 'setQueryInfo', - queryBar, - index: indices, - ruleType: type, - }); - }, - [dispatch] - ); - - const debouncedSetQueryInfo = useRef(debounce(500, setQueryInfo)); - - const setTimeframeSelect = useCallback( - (selection: Unit): void => { - dispatch({ - type: 'setTimeframeSelect', - timeframe: selection, - }); - }, - [dispatch] - ); - - const setRuleTypeChange = useCallback( - (type: Type): void => { - dispatch({ - type: 'setResetRuleTypeChange', - ruleType: type, - }); - }, - [dispatch] - ); - - const setWarnings = useCallback( - (yikes: string[]): void => { - dispatch({ - type: 'setWarnings', - warnings: yikes, - }); - }, - [dispatch] - ); - - const setNoiseWarning = useCallback((): void => { - dispatch({ - type: 'setNoiseWarning', - }); - }, [dispatch]); - - const setShowHistogram = useCallback( - (show: boolean): void => { - dispatch({ - type: 'setShowHistogram', - show, - }); - }, - [dispatch] - ); - - const setThresholdValues = useCallback( - (thresh: Threshold, type: Type): void => { - dispatch({ - type: 'setThresholdQueryVals', - threshold: thresh, - ruleType: type, - }); - }, - [dispatch] - ); - - useEffect(() => { - debouncedSetQueryInfo.current(query, index, ruleType); - }, [index, query, ruleType]); - - useEffect((): void => { - setThresholdValues(threshold, ruleType); - }, [setThresholdValues, threshold, ruleType]); - - useEffect((): void => { - setRuleTypeChange(ruleType); - }, [ruleType, setRuleTypeChange]); - - useEffect((): void => { - switch (ruleType) { - case 'eql': - if (isNoisy(eqlQueryTotal, timeframe)) { - setNoiseWarning(); - } - break; - case 'threshold': - const totalHits = thresholdFieldExists ? buckets.length : matrixHistTotal; - if (isNoisy(totalHits, timeframe)) { - setNoiseWarning(); - } - break; - default: - if (isNoisy(matrixHistTotal, timeframe)) { - setNoiseWarning(); - } - } - }, [ - timeframe, - matrixHistTotal, - eqlQueryTotal, - ruleType, - setNoiseWarning, - thresholdFieldExists, - buckets.length, - ]); - - const handlePreviewEqlQuery = useCallback( - (to: string, from: string): void => { - startEql({ - index, - query: queryString, - from, - to, - interval: timeframe, - }); - }, - [startEql, index, queryString, timeframe] - ); - - const handleSelectPreviewTimeframe = useCallback( - ({ target: { value } }: React.ChangeEvent): void => { - setTimeframeSelect(value as Unit); - }, - [setTimeframeSelect] - ); - - const handlePreviewClicked = useCallback((): void => { - const to = formatDate('now'); - const from = formatDate(`now-1${timeframe}`); - - setWarnings([]); - setShowHistogram(true); - - if (ruleType === 'eql') { - handlePreviewEqlQuery(to, from); - } else { - startNonEql(to, from); - } - }, [setWarnings, setShowHistogram, ruleType, handlePreviewEqlQuery, startNonEql, timeframe]); - - const previewButtonDisabled = useMemo(() => { - return ( - isMatrixHistogramLoading || - eqlQueryLoading || - isDisabled || - query == null || - (query != null && query.query.query === '' && query.filters.length === 0) - ); - }, [eqlQueryLoading, isDisabled, isMatrixHistogramLoading, query]); - - return ( - <> - - - -